Update public source
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
DEFAULT_DATABASE_PATH = PROJECT_ROOT / "database" / "wifi.sqlite3"
|
||||
|
||||
|
||||
def connect_database(path: Path | None = None) -> sqlite3.Connection:
|
||||
database_path = path or DEFAULT_DATABASE_PATH
|
||||
|
||||
if not database_path.is_file():
|
||||
raise FileNotFoundError(f"Database not found: {database_path}")
|
||||
|
||||
conn = sqlite3.connect(database_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
return conn
|
||||
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from .connection import DEFAULT_DATABASE_PATH, connect_database
|
||||
|
||||
|
||||
MIGRATIONS_DIR = Path(__file__).resolve().parents[1] / "migrations"
|
||||
|
||||
|
||||
def _migration_checksum(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _ensure_migrations_table(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS cracklab_migrations (
|
||||
id TEXT PRIMARY KEY,
|
||||
checksum TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _load_applied_migrations(
|
||||
conn: sqlite3.Connection,
|
||||
) -> dict[str, tuple[str, str]]:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, checksum, applied_at
|
||||
FROM cracklab_migrations
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
migration_id: (checksum, applied_at)
|
||||
for migration_id, checksum, applied_at in rows
|
||||
}
|
||||
|
||||
|
||||
def _migration_files(migrations_dir: Path) -> list[Path]:
|
||||
return sorted(
|
||||
path
|
||||
for path in migrations_dir.glob("*.sql")
|
||||
if path.is_file()
|
||||
)
|
||||
|
||||
|
||||
def migrate_database(
|
||||
path: Path | None = None,
|
||||
migrations_dir: Path | None = None,
|
||||
) -> list[str]:
|
||||
conn = connect_database(path)
|
||||
|
||||
try:
|
||||
_ensure_migrations_table(conn)
|
||||
conn.commit()
|
||||
|
||||
applied = _load_applied_migrations(conn)
|
||||
migration_directory = migrations_dir or MIGRATIONS_DIR
|
||||
migrations = _migration_files(migration_directory)
|
||||
newly_applied: list[str] = []
|
||||
|
||||
for migration_path in migrations:
|
||||
migration_id = migration_path.name
|
||||
checksum = _migration_checksum(migration_path)
|
||||
|
||||
if migration_id in applied:
|
||||
stored_checksum, _ = applied[migration_id]
|
||||
|
||||
if stored_checksum != checksum:
|
||||
raise RuntimeError(
|
||||
f"Migration checksum mismatch: {migration_id}"
|
||||
)
|
||||
|
||||
continue
|
||||
|
||||
sql = migration_path.read_text(encoding="utf-8")
|
||||
|
||||
transaction_sql = f"""
|
||||
BEGIN;
|
||||
{sql}
|
||||
INSERT INTO cracklab_migrations (
|
||||
id,
|
||||
checksum,
|
||||
applied_at
|
||||
)
|
||||
VALUES (
|
||||
'{migration_id.replace("'", "''")}',
|
||||
'{checksum}',
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
);
|
||||
COMMIT;
|
||||
"""
|
||||
|
||||
try:
|
||||
conn.executescript(transaction_sql)
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
applied[migration_id] = (
|
||||
checksum,
|
||||
"",
|
||||
)
|
||||
newly_applied.append(migration_id)
|
||||
|
||||
return newly_applied
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
applied = migrate_database(DEFAULT_DATABASE_PATH)
|
||||
|
||||
for migration_id in applied:
|
||||
print(migration_id)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,248 @@
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crack_methods (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crack_method_versions (
|
||||
method_id TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
definition_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (method_id, version),
|
||||
FOREIGN KEY (method_id)
|
||||
REFERENCES crack_methods(id),
|
||||
CHECK (version > 0)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crack_clients (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
last_seen_at TEXT,
|
||||
metadata_json TEXT,
|
||||
CHECK (status IN ('ACTIVE', 'DISABLED'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crack_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
method_id TEXT NOT NULL,
|
||||
method_version INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
queued_at TEXT,
|
||||
started_at TEXT,
|
||||
completed_at TEXT,
|
||||
cancelled_at TEXT,
|
||||
imported_at TEXT,
|
||||
client_id TEXT,
|
||||
hash_file_name TEXT NOT NULL,
|
||||
hash_file_sha256 TEXT NOT NULL,
|
||||
hash_count INTEGER NOT NULL,
|
||||
step_count INTEGER NOT NULL,
|
||||
result_sha256 TEXT,
|
||||
error TEXT,
|
||||
FOREIGN KEY (method_id, method_version)
|
||||
REFERENCES crack_method_versions(method_id, version),
|
||||
FOREIGN KEY (client_id)
|
||||
REFERENCES crack_clients(id),
|
||||
CHECK (
|
||||
status IN (
|
||||
'CREATED',
|
||||
'QUEUED',
|
||||
'RUNNING',
|
||||
'PARTIAL',
|
||||
'COMPLETED',
|
||||
'FAILED',
|
||||
'CANCELLED'
|
||||
)
|
||||
),
|
||||
CHECK (hash_count >= 0),
|
||||
CHECK (step_count > 0)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crack_job_handshakes (
|
||||
job_id TEXT NOT NULL,
|
||||
handshake_id INTEGER NOT NULL,
|
||||
hash22000 TEXT NOT NULL,
|
||||
access_point_id INTEGER,
|
||||
PRIMARY KEY (job_id, handshake_id),
|
||||
FOREIGN KEY (job_id)
|
||||
REFERENCES crack_jobs(id),
|
||||
FOREIGN KEY (handshake_id)
|
||||
REFERENCES handshakes(id),
|
||||
FOREIGN KEY (access_point_id)
|
||||
REFERENCES access_points(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crack_job_steps (
|
||||
job_id TEXT NOT NULL,
|
||||
step_no INTEGER NOT NULL,
|
||||
step_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
session_name TEXT NOT NULL,
|
||||
definition_json TEXT NOT NULL,
|
||||
started_at TEXT,
|
||||
completed_at TEXT,
|
||||
exit_code INTEGER,
|
||||
restore_seen INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT,
|
||||
PRIMARY KEY (job_id, step_no),
|
||||
UNIQUE (job_id, step_id),
|
||||
FOREIGN KEY (job_id)
|
||||
REFERENCES crack_jobs(id),
|
||||
CHECK (step_no > 0),
|
||||
CHECK (
|
||||
status IN (
|
||||
'PENDING',
|
||||
'RUNNING',
|
||||
'COMPLETED',
|
||||
'FAILED',
|
||||
'SKIPPED'
|
||||
)
|
||||
),
|
||||
CHECK (restore_seen IN (0, 1))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crack_reservations (
|
||||
id TEXT PRIMARY KEY,
|
||||
handshake_id INTEGER NOT NULL,
|
||||
method_id TEXT NOT NULL,
|
||||
method_version INTEGER NOT NULL,
|
||||
job_id TEXT NOT NULL,
|
||||
client_id TEXT,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT,
|
||||
released_at TEXT,
|
||||
FOREIGN KEY (handshake_id)
|
||||
REFERENCES handshakes(id),
|
||||
FOREIGN KEY (method_id, method_version)
|
||||
REFERENCES crack_method_versions(method_id, version),
|
||||
FOREIGN KEY (job_id)
|
||||
REFERENCES crack_jobs(id),
|
||||
FOREIGN KEY (client_id)
|
||||
REFERENCES crack_clients(id),
|
||||
CHECK (
|
||||
status IN (
|
||||
'ACTIVE',
|
||||
'RELEASED',
|
||||
'EXPIRED',
|
||||
'CANCELLED'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crack_attempts (
|
||||
handshake_id INTEGER NOT NULL,
|
||||
method_id TEXT NOT NULL,
|
||||
method_version INTEGER NOT NULL,
|
||||
job_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
completed_at TEXT,
|
||||
PRIMARY KEY (handshake_id, method_id, method_version),
|
||||
FOREIGN KEY (handshake_id)
|
||||
REFERENCES handshakes(id),
|
||||
FOREIGN KEY (method_id, method_version)
|
||||
REFERENCES crack_method_versions(method_id, version),
|
||||
FOREIGN KEY (job_id)
|
||||
REFERENCES crack_jobs(id),
|
||||
CHECK (status = 'COMPLETED')
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crack_results (
|
||||
job_id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
result_json TEXT NOT NULL,
|
||||
imported_at TEXT NOT NULL,
|
||||
FOREIGN KEY (job_id)
|
||||
REFERENCES crack_jobs(id),
|
||||
CHECK (
|
||||
status IN (
|
||||
'COMPLETED',
|
||||
'PARTIAL',
|
||||
'FAILED',
|
||||
'CANCELLED'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crack_audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
job_id TEXT,
|
||||
client_id TEXT,
|
||||
handshake_id INTEGER,
|
||||
method_id TEXT,
|
||||
method_version INTEGER,
|
||||
details_json TEXT,
|
||||
FOREIGN KEY (job_id)
|
||||
REFERENCES crack_jobs(id),
|
||||
FOREIGN KEY (client_id)
|
||||
REFERENCES crack_clients(id),
|
||||
FOREIGN KEY (handshake_id)
|
||||
REFERENCES handshakes(id),
|
||||
FOREIGN KEY (method_id, method_version)
|
||||
REFERENCES crack_method_versions(method_id, version)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_crack_reservations_active
|
||||
ON crack_reservations(handshake_id, method_id, method_version)
|
||||
WHERE status = 'ACTIVE';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_method_versions_method
|
||||
ON crack_method_versions(method_id, version);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_jobs_status
|
||||
ON crack_jobs(status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_jobs_method_status
|
||||
ON crack_jobs(method_id, method_version, status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_jobs_client_status
|
||||
ON crack_jobs(client_id, status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_job_handshakes_handshake
|
||||
ON crack_job_handshakes(handshake_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_job_handshakes_job
|
||||
ON crack_job_handshakes(job_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_job_steps_status
|
||||
ON crack_job_steps(job_id, status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_reservations_handshake
|
||||
ON crack_reservations(handshake_id, method_id, method_version);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_reservations_job
|
||||
ON crack_reservations(job_id, status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_reservations_client
|
||||
ON crack_reservations(client_id, status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_attempts_method
|
||||
ON crack_attempts(method_id, method_version, status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_attempts_job
|
||||
ON crack_attempts(job_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_attempts_handshake
|
||||
ON crack_attempts(handshake_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_audit_job
|
||||
ON crack_audit_log(job_id, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_audit_client
|
||||
ON crack_audit_log(client_id, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_audit_handshake
|
||||
ON crack_audit_log(handshake_id, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crack_audit_event
|
||||
ON crack_audit_log(event_type, created_at);
|
||||
@@ -0,0 +1,162 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
class AttemptsRepository:
|
||||
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
@staticmethod
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace(
|
||||
"+00:00",
|
||||
"Z",
|
||||
)
|
||||
|
||||
def create_attempt(
|
||||
self,
|
||||
handshake_id: int,
|
||||
method_id: str,
|
||||
method_version: int,
|
||||
job_id: str,
|
||||
completed_at: str | None = None,
|
||||
) -> None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO crack_attempts (
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
job_id,
|
||||
status,
|
||||
completed_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
job_id,
|
||||
"COMPLETED",
|
||||
completed_at or self._now(),
|
||||
),
|
||||
)
|
||||
|
||||
def get_attempt(
|
||||
self,
|
||||
handshake_id: int,
|
||||
method_id: str,
|
||||
method_version: int,
|
||||
) -> sqlite3.Row | None:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
job_id,
|
||||
status,
|
||||
completed_at
|
||||
FROM crack_attempts
|
||||
WHERE handshake_id = ?
|
||||
AND method_id = ?
|
||||
AND method_version = ?
|
||||
""",
|
||||
(
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
),
|
||||
).fetchone()
|
||||
|
||||
def has_completed(
|
||||
self,
|
||||
handshake_id: int,
|
||||
method_id: str,
|
||||
method_version: int,
|
||||
) -> bool:
|
||||
row = self._conn.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM crack_attempts
|
||||
WHERE handshake_id = ?
|
||||
AND method_id = ?
|
||||
AND method_version = ?
|
||||
AND status = 'COMPLETED'
|
||||
""",
|
||||
(
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
),
|
||||
).fetchone()
|
||||
|
||||
return row is not None
|
||||
|
||||
def list_handshake_attempts(
|
||||
self,
|
||||
handshake_id: int,
|
||||
) -> list[sqlite3.Row]:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
job_id,
|
||||
status,
|
||||
completed_at
|
||||
FROM crack_attempts
|
||||
WHERE handshake_id = ?
|
||||
ORDER BY method_id, method_version
|
||||
""",
|
||||
(handshake_id,),
|
||||
).fetchall()
|
||||
|
||||
def list_job_attempts(
|
||||
self,
|
||||
job_id: str,
|
||||
) -> list[sqlite3.Row]:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
job_id,
|
||||
status,
|
||||
completed_at
|
||||
FROM crack_attempts
|
||||
WHERE job_id = ?
|
||||
ORDER BY handshake_id, method_id, method_version
|
||||
""",
|
||||
(job_id,),
|
||||
).fetchall()
|
||||
|
||||
def list_method_attempts(
|
||||
self,
|
||||
method_id: str,
|
||||
method_version: int,
|
||||
) -> list[sqlite3.Row]:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
job_id,
|
||||
status,
|
||||
completed_at
|
||||
FROM crack_attempts
|
||||
WHERE method_id = ?
|
||||
AND method_version = ?
|
||||
ORDER BY handshake_id
|
||||
""",
|
||||
(
|
||||
method_id,
|
||||
method_version,
|
||||
),
|
||||
).fetchall()
|
||||
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
class AuditRepository:
|
||||
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
@staticmethod
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace(
|
||||
"+00:00",
|
||||
"Z",
|
||||
)
|
||||
|
||||
def create_event(
|
||||
self,
|
||||
event_type: str,
|
||||
job_id: str | None = None,
|
||||
client_id: str | None = None,
|
||||
handshake_id: int | None = None,
|
||||
method_id: str | None = None,
|
||||
method_version: int | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
created_at: str | None = None,
|
||||
) -> int:
|
||||
cursor = self._conn.execute(
|
||||
"""
|
||||
INSERT INTO crack_audit_log (
|
||||
created_at,
|
||||
event_type,
|
||||
job_id,
|
||||
client_id,
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
details_json
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
created_at or self._now(),
|
||||
event_type,
|
||||
job_id,
|
||||
client_id,
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
(
|
||||
json.dumps(
|
||||
details,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
)
|
||||
if details is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
def get_event(
|
||||
self,
|
||||
event_id: int,
|
||||
) -> sqlite3.Row | None:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
created_at,
|
||||
event_type,
|
||||
job_id,
|
||||
client_id,
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
details_json
|
||||
FROM crack_audit_log
|
||||
WHERE id = ?
|
||||
""",
|
||||
(event_id,),
|
||||
).fetchone()
|
||||
|
||||
def list_events(
|
||||
self,
|
||||
event_type: str | None = None,
|
||||
job_id: str | None = None,
|
||||
client_id: str | None = None,
|
||||
handshake_id: int | None = None,
|
||||
method_id: str | None = None,
|
||||
method_version: int | None = None,
|
||||
) -> list[sqlite3.Row]:
|
||||
query = """
|
||||
SELECT
|
||||
id,
|
||||
created_at,
|
||||
event_type,
|
||||
job_id,
|
||||
client_id,
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
details_json
|
||||
FROM crack_audit_log
|
||||
WHERE 1 = 1
|
||||
"""
|
||||
params: list[Any] = []
|
||||
|
||||
if event_type is not None:
|
||||
query += "\n AND event_type = ?"
|
||||
params.append(event_type)
|
||||
|
||||
if job_id is not None:
|
||||
query += "\n AND job_id = ?"
|
||||
params.append(job_id)
|
||||
|
||||
if client_id is not None:
|
||||
query += "\n AND client_id = ?"
|
||||
params.append(client_id)
|
||||
|
||||
if handshake_id is not None:
|
||||
query += "\n AND handshake_id = ?"
|
||||
params.append(handshake_id)
|
||||
|
||||
if method_id is not None:
|
||||
query += "\n AND method_id = ?"
|
||||
params.append(method_id)
|
||||
|
||||
if method_version is not None:
|
||||
query += "\n AND method_version = ?"
|
||||
params.append(method_version)
|
||||
|
||||
query += "\n ORDER BY created_at DESC, id DESC"
|
||||
|
||||
return self._conn.execute(query, params).fetchall()
|
||||
|
||||
@staticmethod
|
||||
def decode_details(row: sqlite3.Row) -> dict[str, Any] | None:
|
||||
if row["details_json"] is None:
|
||||
return None
|
||||
|
||||
return json.loads(row["details_json"])
|
||||
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ClientsRepository:
|
||||
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
@staticmethod
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace(
|
||||
"+00:00",
|
||||
"Z",
|
||||
)
|
||||
|
||||
def create_client(
|
||||
self,
|
||||
client_id: str,
|
||||
name: str,
|
||||
token_hash: str | None = None,
|
||||
status: str = "ACTIVE",
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
metadata_json = (
|
||||
json.dumps(
|
||||
metadata,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
)
|
||||
if metadata is not None
|
||||
else None
|
||||
)
|
||||
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO crack_clients (
|
||||
id,
|
||||
name,
|
||||
token_hash,
|
||||
status,
|
||||
created_at,
|
||||
last_seen_at,
|
||||
metadata_json
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
client_id,
|
||||
name,
|
||||
token_hash,
|
||||
status,
|
||||
self._now(),
|
||||
None,
|
||||
metadata_json,
|
||||
),
|
||||
)
|
||||
|
||||
def get_client(self, client_id: str) -> sqlite3.Row | None:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
token_hash,
|
||||
status,
|
||||
created_at,
|
||||
last_seen_at,
|
||||
metadata_json
|
||||
FROM crack_clients
|
||||
WHERE id = ?
|
||||
""",
|
||||
(client_id,),
|
||||
).fetchone()
|
||||
|
||||
def list_clients(self) -> list[sqlite3.Row]:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
token_hash,
|
||||
status,
|
||||
created_at,
|
||||
last_seen_at,
|
||||
metadata_json
|
||||
FROM crack_clients
|
||||
ORDER BY id
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
def update_last_seen(self, client_id: str) -> None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
UPDATE crack_clients
|
||||
SET last_seen_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
self._now(),
|
||||
client_id,
|
||||
),
|
||||
)
|
||||
|
||||
def update_status(self, client_id: str, status: str) -> None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
UPDATE crack_clients
|
||||
SET status = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
status,
|
||||
client_id,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
class CredentialsRepository:
|
||||
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
@staticmethod
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace(
|
||||
"+00:00",
|
||||
"Z",
|
||||
)
|
||||
|
||||
def get_credential(
|
||||
self,
|
||||
access_point_id: int,
|
||||
handshake_id: int,
|
||||
password: str,
|
||||
) -> sqlite3.Row | None:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
access_point_id,
|
||||
handshake_id,
|
||||
password,
|
||||
source,
|
||||
created_at,
|
||||
verified
|
||||
FROM credentials
|
||||
WHERE access_point_id = ?
|
||||
AND handshake_id = ?
|
||||
AND password = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(
|
||||
access_point_id,
|
||||
handshake_id,
|
||||
password,
|
||||
),
|
||||
).fetchone()
|
||||
|
||||
def create_hashcat_credential(
|
||||
self,
|
||||
access_point_id: int,
|
||||
handshake_id: int,
|
||||
password: str,
|
||||
) -> int:
|
||||
cursor = self._conn.execute(
|
||||
"""
|
||||
INSERT INTO credentials (
|
||||
access_point_id,
|
||||
handshake_id,
|
||||
password,
|
||||
source,
|
||||
created_at,
|
||||
verified
|
||||
)
|
||||
VALUES (?, ?, ?, 'hashcat', ?, 0)
|
||||
""",
|
||||
(
|
||||
access_point_id,
|
||||
handshake_id,
|
||||
password,
|
||||
self._now(),
|
||||
),
|
||||
)
|
||||
|
||||
self._conn.execute(
|
||||
"""
|
||||
UPDATE access_points
|
||||
SET is_cracked = 1
|
||||
WHERE id = ?
|
||||
""",
|
||||
(access_point_id,),
|
||||
)
|
||||
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
def mark_access_point_cracked(
|
||||
self,
|
||||
access_point_id: int,
|
||||
) -> None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
UPDATE access_points
|
||||
SET is_cracked = 1
|
||||
WHERE id = ?
|
||||
""",
|
||||
(access_point_id,),
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
|
||||
class HandshakesRepository:
|
||||
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def list_eligible(
|
||||
self,
|
||||
method_id: str,
|
||||
method_version: int,
|
||||
limit: int,
|
||||
) -> list[sqlite3.Row]:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
h.id AS handshake_id,
|
||||
h.hash22000,
|
||||
h.access_point_id,
|
||||
h.captured_at
|
||||
FROM handshakes AS h
|
||||
WHERE h.hash22000 IS NOT NULL
|
||||
AND TRIM(h.hash22000) <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM credentials AS c
|
||||
WHERE c.handshake_id = h.id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM crack_attempts AS a
|
||||
WHERE a.handshake_id = h.id
|
||||
AND a.method_id = ?
|
||||
AND a.method_version = ?
|
||||
AND a.status = 'COMPLETED'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM crack_reservations AS r
|
||||
WHERE r.handshake_id = h.id
|
||||
AND r.method_id = ?
|
||||
AND r.method_version = ?
|
||||
AND r.status = 'ACTIVE'
|
||||
)
|
||||
ORDER BY h.id
|
||||
LIMIT ?
|
||||
""",
|
||||
(
|
||||
method_id,
|
||||
method_version,
|
||||
method_id,
|
||||
method_version,
|
||||
limit,
|
||||
),
|
||||
).fetchall()
|
||||
@@ -0,0 +1,452 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
class JobsRepository:
|
||||
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
@staticmethod
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace(
|
||||
"+00:00",
|
||||
"Z",
|
||||
)
|
||||
|
||||
def create_job(
|
||||
self,
|
||||
job_id: str,
|
||||
method_id: str,
|
||||
method_version: int,
|
||||
status: str,
|
||||
hash_file_name: str,
|
||||
hash_file_sha256: str,
|
||||
hash_count: int,
|
||||
step_count: int,
|
||||
client_id: str | None = None,
|
||||
) -> str:
|
||||
created_at = self._now()
|
||||
queued_at = created_at if status == "QUEUED" else None
|
||||
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO crack_jobs (
|
||||
id,
|
||||
method_id,
|
||||
method_version,
|
||||
status,
|
||||
created_at,
|
||||
queued_at,
|
||||
started_at,
|
||||
completed_at,
|
||||
cancelled_at,
|
||||
imported_at,
|
||||
client_id,
|
||||
hash_file_name,
|
||||
hash_file_sha256,
|
||||
hash_count,
|
||||
step_count,
|
||||
result_sha256,
|
||||
error
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
job_id,
|
||||
method_id,
|
||||
method_version,
|
||||
status,
|
||||
created_at,
|
||||
queued_at,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
client_id,
|
||||
hash_file_name,
|
||||
hash_file_sha256,
|
||||
hash_count,
|
||||
step_count,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)
|
||||
|
||||
return created_at
|
||||
|
||||
def get_job(self, job_id: str) -> sqlite3.Row | None:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
method_id,
|
||||
method_version,
|
||||
status,
|
||||
created_at,
|
||||
queued_at,
|
||||
started_at,
|
||||
completed_at,
|
||||
cancelled_at,
|
||||
imported_at,
|
||||
client_id,
|
||||
hash_file_name,
|
||||
hash_file_sha256,
|
||||
hash_count,
|
||||
step_count,
|
||||
result_sha256,
|
||||
error
|
||||
FROM crack_jobs
|
||||
WHERE id = ?
|
||||
""",
|
||||
(job_id,),
|
||||
).fetchone()
|
||||
|
||||
def list_jobs(
|
||||
self,
|
||||
status: str | None = None,
|
||||
method_id: str | None = None,
|
||||
client_id: str | None = None,
|
||||
) -> list[sqlite3.Row]:
|
||||
query = """
|
||||
SELECT
|
||||
id,
|
||||
method_id,
|
||||
method_version,
|
||||
status,
|
||||
created_at,
|
||||
queued_at,
|
||||
started_at,
|
||||
completed_at,
|
||||
cancelled_at,
|
||||
imported_at,
|
||||
client_id,
|
||||
hash_file_name,
|
||||
hash_file_sha256,
|
||||
hash_count,
|
||||
step_count,
|
||||
result_sha256,
|
||||
error
|
||||
FROM crack_jobs
|
||||
WHERE 1 = 1
|
||||
"""
|
||||
params: list[Any] = []
|
||||
|
||||
if status is not None:
|
||||
query += "\n AND status = ?"
|
||||
params.append(status)
|
||||
|
||||
if method_id is not None:
|
||||
query += "\n AND method_id = ?"
|
||||
params.append(method_id)
|
||||
|
||||
if client_id is not None:
|
||||
query += "\n AND client_id = ?"
|
||||
params.append(client_id)
|
||||
|
||||
query += "\n ORDER BY created_at DESC, id DESC"
|
||||
|
||||
return self._conn.execute(query, params).fetchall()
|
||||
|
||||
def update_job_status(
|
||||
self,
|
||||
job_id: str,
|
||||
status: str,
|
||||
) -> None:
|
||||
now = self._now()
|
||||
|
||||
timestamp_column = {
|
||||
"QUEUED": "queued_at",
|
||||
"RUNNING": "started_at",
|
||||
"COMPLETED": "completed_at",
|
||||
"CANCELLED": "cancelled_at",
|
||||
}.get(status)
|
||||
|
||||
if timestamp_column is None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
UPDATE crack_jobs
|
||||
SET status = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
status,
|
||||
job_id,
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
self._conn.execute(
|
||||
f"""
|
||||
UPDATE crack_jobs
|
||||
SET status = ?,
|
||||
{timestamp_column} = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
status,
|
||||
now,
|
||||
job_id,
|
||||
),
|
||||
)
|
||||
|
||||
def assign_client(
|
||||
self,
|
||||
job_id: str,
|
||||
client_id: str | None,
|
||||
) -> None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
UPDATE crack_jobs
|
||||
SET client_id = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
client_id,
|
||||
job_id,
|
||||
),
|
||||
)
|
||||
|
||||
def update_job_result(
|
||||
self,
|
||||
job_id: str,
|
||||
result_sha256: str | None = None,
|
||||
error: str | None = None,
|
||||
imported_at: str | None = None,
|
||||
) -> None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
UPDATE crack_jobs
|
||||
SET result_sha256 = ?,
|
||||
error = ?,
|
||||
imported_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
result_sha256,
|
||||
error,
|
||||
imported_at,
|
||||
job_id,
|
||||
),
|
||||
)
|
||||
|
||||
def add_job_handshake(
|
||||
self,
|
||||
job_id: str,
|
||||
handshake_id: int,
|
||||
hash22000: str,
|
||||
access_point_id: int | None = None,
|
||||
) -> None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO crack_job_handshakes (
|
||||
job_id,
|
||||
handshake_id,
|
||||
hash22000,
|
||||
access_point_id
|
||||
)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
job_id,
|
||||
handshake_id,
|
||||
hash22000,
|
||||
access_point_id,
|
||||
),
|
||||
)
|
||||
|
||||
def get_job_handshake(
|
||||
self,
|
||||
job_id: str,
|
||||
handshake_id: int,
|
||||
) -> sqlite3.Row | None:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
job_id,
|
||||
handshake_id,
|
||||
hash22000,
|
||||
access_point_id
|
||||
FROM crack_job_handshakes
|
||||
WHERE job_id = ?
|
||||
AND handshake_id = ?
|
||||
""",
|
||||
(
|
||||
job_id,
|
||||
handshake_id,
|
||||
),
|
||||
).fetchone()
|
||||
|
||||
def list_job_handshakes(
|
||||
self,
|
||||
job_id: str,
|
||||
) -> list[sqlite3.Row]:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
job_id,
|
||||
handshake_id,
|
||||
hash22000,
|
||||
access_point_id
|
||||
FROM crack_job_handshakes
|
||||
WHERE job_id = ?
|
||||
ORDER BY handshake_id
|
||||
""",
|
||||
(job_id,),
|
||||
).fetchall()
|
||||
|
||||
def create_job_step(
|
||||
self,
|
||||
job_id: str,
|
||||
step_no: int,
|
||||
step_id: str,
|
||||
status: str,
|
||||
session_name: str,
|
||||
definition: dict[str, Any],
|
||||
) -> None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO crack_job_steps (
|
||||
job_id,
|
||||
step_no,
|
||||
step_id,
|
||||
status,
|
||||
session_name,
|
||||
definition_json,
|
||||
started_at,
|
||||
completed_at,
|
||||
exit_code,
|
||||
restore_seen,
|
||||
error
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
job_id,
|
||||
step_no,
|
||||
step_id,
|
||||
status,
|
||||
session_name,
|
||||
json.dumps(
|
||||
definition,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
None,
|
||||
),
|
||||
)
|
||||
|
||||
def get_job_step(
|
||||
self,
|
||||
job_id: str,
|
||||
step_no: int,
|
||||
) -> sqlite3.Row | None:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
job_id,
|
||||
step_no,
|
||||
step_id,
|
||||
status,
|
||||
session_name,
|
||||
definition_json,
|
||||
started_at,
|
||||
completed_at,
|
||||
exit_code,
|
||||
restore_seen,
|
||||
error
|
||||
FROM crack_job_steps
|
||||
WHERE job_id = ?
|
||||
AND step_no = ?
|
||||
""",
|
||||
(
|
||||
job_id,
|
||||
step_no,
|
||||
),
|
||||
).fetchone()
|
||||
|
||||
def list_job_steps(
|
||||
self,
|
||||
job_id: str,
|
||||
) -> list[sqlite3.Row]:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
job_id,
|
||||
step_no,
|
||||
step_id,
|
||||
status,
|
||||
session_name,
|
||||
definition_json,
|
||||
started_at,
|
||||
completed_at,
|
||||
exit_code,
|
||||
restore_seen,
|
||||
error
|
||||
FROM crack_job_steps
|
||||
WHERE job_id = ?
|
||||
ORDER BY step_no
|
||||
""",
|
||||
(job_id,),
|
||||
).fetchall()
|
||||
|
||||
def update_job_step(
|
||||
self,
|
||||
job_id: str,
|
||||
step_no: int,
|
||||
status: str | None = None,
|
||||
started_at: str | None = None,
|
||||
completed_at: str | None = None,
|
||||
exit_code: int | None = None,
|
||||
restore_seen: bool | None = None,
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
fields: list[str] = []
|
||||
params: list[Any] = []
|
||||
|
||||
if status is not None:
|
||||
fields.append("status = ?")
|
||||
params.append(status)
|
||||
|
||||
if started_at is not None:
|
||||
fields.append("started_at = ?")
|
||||
params.append(started_at)
|
||||
|
||||
if completed_at is not None:
|
||||
fields.append("completed_at = ?")
|
||||
params.append(completed_at)
|
||||
|
||||
if exit_code is not None:
|
||||
fields.append("exit_code = ?")
|
||||
params.append(exit_code)
|
||||
|
||||
if restore_seen is not None:
|
||||
fields.append("restore_seen = ?")
|
||||
params.append(int(restore_seen))
|
||||
|
||||
if error is not None:
|
||||
fields.append("error = ?")
|
||||
params.append(error)
|
||||
|
||||
if not fields:
|
||||
return
|
||||
|
||||
params.extend((job_id, step_no))
|
||||
|
||||
self._conn.execute(
|
||||
f"""
|
||||
UPDATE crack_job_steps
|
||||
SET {", ".join(fields)}
|
||||
WHERE job_id = ?
|
||||
AND step_no = ?
|
||||
""",
|
||||
params,
|
||||
)
|
||||
@@ -0,0 +1,149 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
class MethodsRepository:
|
||||
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
@staticmethod
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace(
|
||||
"+00:00",
|
||||
"Z",
|
||||
)
|
||||
|
||||
def create_method(
|
||||
self,
|
||||
method_id: str,
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
) -> None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO crack_methods (
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
created_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
method_id,
|
||||
name,
|
||||
description,
|
||||
self._now(),
|
||||
),
|
||||
)
|
||||
|
||||
def get_method(self, method_id: str) -> sqlite3.Row | None:
|
||||
row = self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
created_at
|
||||
FROM crack_methods
|
||||
WHERE id = ?
|
||||
""",
|
||||
(method_id,),
|
||||
).fetchone()
|
||||
|
||||
return row
|
||||
|
||||
def list_methods(self) -> list[sqlite3.Row]:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
created_at
|
||||
FROM crack_methods
|
||||
ORDER BY id
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
def create_method_version(
|
||||
self,
|
||||
method_id: str,
|
||||
version: int,
|
||||
definition: dict[str, Any],
|
||||
) -> None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO crack_method_versions (
|
||||
method_id,
|
||||
version,
|
||||
definition_json,
|
||||
created_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
method_id,
|
||||
version,
|
||||
json.dumps(
|
||||
definition,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
),
|
||||
self._now(),
|
||||
),
|
||||
)
|
||||
|
||||
def get_method_version(
|
||||
self,
|
||||
method_id: str,
|
||||
version: int,
|
||||
) -> sqlite3.Row | None:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
method_id,
|
||||
version,
|
||||
definition_json,
|
||||
created_at
|
||||
FROM crack_method_versions
|
||||
WHERE method_id = ?
|
||||
AND version = ?
|
||||
""",
|
||||
(method_id, version),
|
||||
).fetchone()
|
||||
|
||||
def list_method_versions(
|
||||
self,
|
||||
method_id: str,
|
||||
) -> list[sqlite3.Row]:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
method_id,
|
||||
version,
|
||||
definition_json,
|
||||
created_at
|
||||
FROM crack_method_versions
|
||||
WHERE method_id = ?
|
||||
ORDER BY version
|
||||
""",
|
||||
(method_id,),
|
||||
).fetchall()
|
||||
|
||||
def get_next_method_version(self, method_id: str) -> int:
|
||||
row = self._conn.execute(
|
||||
"""
|
||||
SELECT COALESCE(MAX(version), 0) + 1
|
||||
FROM crack_method_versions
|
||||
WHERE method_id = ?
|
||||
""",
|
||||
(method_id,),
|
||||
).fetchone()
|
||||
|
||||
return int(row[0])
|
||||
@@ -0,0 +1,209 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
class ReservationsRepository:
|
||||
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
@staticmethod
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace(
|
||||
"+00:00",
|
||||
"Z",
|
||||
)
|
||||
|
||||
def create_reservation(
|
||||
self,
|
||||
reservation_id: str,
|
||||
handshake_id: int,
|
||||
method_id: str,
|
||||
method_version: int,
|
||||
job_id: str,
|
||||
client_id: str | None = None,
|
||||
expires_at: str | None = None,
|
||||
) -> None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO crack_reservations (
|
||||
id,
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
job_id,
|
||||
client_id,
|
||||
status,
|
||||
created_at,
|
||||
expires_at,
|
||||
released_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
reservation_id,
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
job_id,
|
||||
client_id,
|
||||
"ACTIVE",
|
||||
self._now(),
|
||||
expires_at,
|
||||
None,
|
||||
),
|
||||
)
|
||||
|
||||
def get_reservation(
|
||||
self,
|
||||
reservation_id: str,
|
||||
) -> sqlite3.Row | None:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
job_id,
|
||||
client_id,
|
||||
status,
|
||||
created_at,
|
||||
expires_at,
|
||||
released_at
|
||||
FROM crack_reservations
|
||||
WHERE id = ?
|
||||
""",
|
||||
(reservation_id,),
|
||||
).fetchone()
|
||||
|
||||
def get_active_reservation(
|
||||
self,
|
||||
handshake_id: int,
|
||||
method_id: str,
|
||||
method_version: int,
|
||||
) -> sqlite3.Row | None:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
job_id,
|
||||
client_id,
|
||||
status,
|
||||
created_at,
|
||||
expires_at,
|
||||
released_at
|
||||
FROM crack_reservations
|
||||
WHERE handshake_id = ?
|
||||
AND method_id = ?
|
||||
AND method_version = ?
|
||||
AND status = 'ACTIVE'
|
||||
""",
|
||||
(
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
),
|
||||
).fetchone()
|
||||
|
||||
def list_job_reservations(
|
||||
self,
|
||||
job_id: str,
|
||||
) -> list[sqlite3.Row]:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
job_id,
|
||||
client_id,
|
||||
status,
|
||||
created_at,
|
||||
expires_at,
|
||||
released_at
|
||||
FROM crack_reservations
|
||||
WHERE job_id = ?
|
||||
ORDER BY created_at, id
|
||||
""",
|
||||
(job_id,),
|
||||
).fetchall()
|
||||
|
||||
def list_client_reservations(
|
||||
self,
|
||||
client_id: str,
|
||||
status: str | None = None,
|
||||
) -> list[sqlite3.Row]:
|
||||
query = """
|
||||
SELECT
|
||||
id,
|
||||
handshake_id,
|
||||
method_id,
|
||||
method_version,
|
||||
job_id,
|
||||
client_id,
|
||||
status,
|
||||
created_at,
|
||||
expires_at,
|
||||
released_at
|
||||
FROM crack_reservations
|
||||
WHERE client_id = ?
|
||||
"""
|
||||
params: list[object] = [client_id]
|
||||
|
||||
if status is not None:
|
||||
query += "\n AND status = ?"
|
||||
params.append(status)
|
||||
|
||||
query += "\n ORDER BY created_at, id"
|
||||
|
||||
return self._conn.execute(query, params).fetchall()
|
||||
|
||||
def update_status(
|
||||
self,
|
||||
reservation_id: str,
|
||||
status: str,
|
||||
released_at: str | None = None,
|
||||
) -> None:
|
||||
if released_at is None and status in {
|
||||
"RELEASED",
|
||||
"EXPIRED",
|
||||
"CANCELLED",
|
||||
}:
|
||||
released_at = self._now()
|
||||
|
||||
self._conn.execute(
|
||||
"""
|
||||
UPDATE crack_reservations
|
||||
SET status = ?,
|
||||
released_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
status,
|
||||
released_at,
|
||||
reservation_id,
|
||||
),
|
||||
)
|
||||
|
||||
def update_expires_at(
|
||||
self,
|
||||
reservation_id: str,
|
||||
expires_at: str | None,
|
||||
) -> None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
UPDATE crack_reservations
|
||||
SET expires_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
expires_at,
|
||||
reservation_id,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ResultsRepository:
|
||||
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
@staticmethod
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace(
|
||||
"+00:00",
|
||||
"Z",
|
||||
)
|
||||
|
||||
def create_result(
|
||||
self,
|
||||
job_id: str,
|
||||
status: str,
|
||||
result: dict[str, Any],
|
||||
imported_at: str | None = None,
|
||||
) -> None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO crack_results (
|
||||
job_id,
|
||||
status,
|
||||
result_json,
|
||||
imported_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
job_id,
|
||||
status,
|
||||
json.dumps(
|
||||
result,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
),
|
||||
imported_at or self._now(),
|
||||
),
|
||||
)
|
||||
|
||||
def get_result(
|
||||
self,
|
||||
job_id: str,
|
||||
) -> sqlite3.Row | None:
|
||||
return self._conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
job_id,
|
||||
status,
|
||||
result_json,
|
||||
imported_at
|
||||
FROM crack_results
|
||||
WHERE job_id = ?
|
||||
""",
|
||||
(job_id,),
|
||||
).fetchone()
|
||||
|
||||
def list_results(
|
||||
self,
|
||||
status: str | None = None,
|
||||
) -> list[sqlite3.Row]:
|
||||
query = """
|
||||
SELECT
|
||||
job_id,
|
||||
status,
|
||||
result_json,
|
||||
imported_at
|
||||
FROM crack_results
|
||||
WHERE 1 = 1
|
||||
"""
|
||||
params: list[Any] = []
|
||||
|
||||
if status is not None:
|
||||
query += "\n AND status = ?"
|
||||
params.append(status)
|
||||
|
||||
query += "\n ORDER BY imported_at DESC, job_id DESC"
|
||||
|
||||
return self._conn.execute(query, params).fetchall()
|
||||
|
||||
@staticmethod
|
||||
def decode_result(row: sqlite3.Row) -> dict[str, Any]:
|
||||
return json.loads(row["result_json"])
|
||||
@@ -0,0 +1,12 @@
|
||||
from .job_issuance import JobIssuanceService, IssuedJob
|
||||
from .job_result import JobResultError, JobResultService
|
||||
from .methods import MethodService, MethodServiceError
|
||||
|
||||
__all__ = [
|
||||
"IssuedJob",
|
||||
"JobIssuanceService",
|
||||
"JobResultError",
|
||||
"JobResultService",
|
||||
"MethodService",
|
||||
"MethodServiceError",
|
||||
]
|
||||
@@ -0,0 +1,348 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import yaml
|
||||
|
||||
from cracklab.server.repositories.jobs import JobsRepository
|
||||
from cracklab.server.repositories.methods import MethodsRepository
|
||||
from cracklab.server.services.method_definition import (
|
||||
validate_method_definition,
|
||||
)
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
CONFIG_FILE = PROJECT_ROOT / "config" / "config.yaml"
|
||||
|
||||
|
||||
class JobExportError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class JobExportService:
|
||||
def __init__(
|
||||
self,
|
||||
conn,
|
||||
timezone_name: str | None = None,
|
||||
) -> None:
|
||||
self._conn = conn
|
||||
self._jobs = JobsRepository(conn)
|
||||
self._methods = MethodsRepository(conn)
|
||||
self._timezone_name = (
|
||||
timezone_name
|
||||
if timezone_name is not None
|
||||
else self._load_timezone()
|
||||
)
|
||||
|
||||
try:
|
||||
self._timezone = ZoneInfo(self._timezone_name)
|
||||
except Exception as exc:
|
||||
raise JobExportError(
|
||||
f"Invalid timezone: {self._timezone_name}"
|
||||
) from exc
|
||||
|
||||
@staticmethod
|
||||
def _load_timezone() -> str:
|
||||
if not CONFIG_FILE.is_file():
|
||||
raise JobExportError(
|
||||
f"Configuration file not found: {CONFIG_FILE}"
|
||||
)
|
||||
|
||||
try:
|
||||
with CONFIG_FILE.open(
|
||||
"r",
|
||||
encoding="utf-8",
|
||||
) as fp:
|
||||
config = yaml.safe_load(fp) or {}
|
||||
except Exception as exc:
|
||||
raise JobExportError(
|
||||
f"Failed to load configuration: {CONFIG_FILE}"
|
||||
) from exc
|
||||
|
||||
timezone_name = config.get(
|
||||
"timezone",
|
||||
"Europe/Moscow",
|
||||
)
|
||||
|
||||
if not isinstance(timezone_name, str) or not timezone_name:
|
||||
raise JobExportError(
|
||||
"Configuration timezone must be a non-empty string"
|
||||
)
|
||||
|
||||
return timezone_name
|
||||
|
||||
@staticmethod
|
||||
def _parse_utc(text: str) -> datetime:
|
||||
try:
|
||||
value = datetime.fromisoformat(text)
|
||||
except ValueError as exc:
|
||||
raise JobExportError(
|
||||
f"Invalid job created_at: {text}"
|
||||
) from exc
|
||||
|
||||
if value.tzinfo is None:
|
||||
raise JobExportError(
|
||||
f"Job timestamp is not timezone-aware: {text}"
|
||||
)
|
||||
|
||||
value = value.astimezone(timezone.utc)
|
||||
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _unique_hash_content(rows) -> tuple[str, int]:
|
||||
unique_hashes: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for row in rows:
|
||||
hash22000 = str(row["hash22000"]).strip()
|
||||
|
||||
if not hash22000:
|
||||
raise JobExportError(
|
||||
"Job contains an empty hash22000 value"
|
||||
)
|
||||
|
||||
if hash22000 in seen:
|
||||
continue
|
||||
|
||||
seen.add(hash22000)
|
||||
unique_hashes.append(hash22000)
|
||||
|
||||
content = "\n".join(unique_hashes) + "\n"
|
||||
|
||||
return content, len(unique_hashes)
|
||||
|
||||
@staticmethod
|
||||
def _sha256_text(content: str) -> str:
|
||||
return hashlib.sha256(
|
||||
content.encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _json_bytes(data: dict) -> bytes:
|
||||
return (
|
||||
json.dumps(
|
||||
data,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
+ "\n"
|
||||
).encode("utf-8")
|
||||
|
||||
def export_job(
|
||||
self,
|
||||
job_id: str,
|
||||
output_dir: str | Path,
|
||||
) -> Path:
|
||||
job = self._jobs.get_job(job_id)
|
||||
|
||||
if job is None:
|
||||
raise JobExportError(
|
||||
f"Job not found: {job_id}"
|
||||
)
|
||||
|
||||
method_version = self._methods.get_method_version(
|
||||
str(job["method_id"]),
|
||||
int(job["method_version"]),
|
||||
)
|
||||
|
||||
if method_version is None:
|
||||
raise JobExportError(
|
||||
"Method version not found: "
|
||||
f"{job['method_id']} v{job['method_version']}"
|
||||
)
|
||||
|
||||
try:
|
||||
method_definition = json.loads(
|
||||
method_version["definition_json"]
|
||||
)
|
||||
validate_method_definition(
|
||||
method_definition
|
||||
)
|
||||
except (json.JSONDecodeError, TypeError, ValueError) as exc:
|
||||
raise JobExportError(
|
||||
"Invalid method version definition: "
|
||||
f"{job['method_id']} v{job['method_version']}"
|
||||
) from exc
|
||||
|
||||
hash_mode = int(
|
||||
method_definition["hash_mode"]
|
||||
)
|
||||
|
||||
handshake_rows = self._jobs.list_job_handshakes(
|
||||
job_id
|
||||
)
|
||||
step_rows = self._jobs.list_job_steps(
|
||||
job_id
|
||||
)
|
||||
|
||||
hash_content, hash_count = self._unique_hash_content(
|
||||
handshake_rows
|
||||
)
|
||||
|
||||
hash_sha256 = self._sha256_text(
|
||||
hash_content
|
||||
)
|
||||
|
||||
if hash_sha256 != job["hash_file_sha256"]:
|
||||
raise JobExportError(
|
||||
"Job hash snapshot SHA-256 mismatch: "
|
||||
f"expected {job['hash_file_sha256']}, "
|
||||
f"calculated {hash_sha256}"
|
||||
)
|
||||
|
||||
if hash_count != int(job["hash_count"]):
|
||||
raise JobExportError(
|
||||
"Job hash count mismatch: "
|
||||
f"expected {job['hash_count']}, "
|
||||
f"calculated {hash_count}"
|
||||
)
|
||||
|
||||
if len(handshake_rows) == 0:
|
||||
raise JobExportError(
|
||||
f"Job contains no handshake snapshot: {job_id}"
|
||||
)
|
||||
|
||||
if len(step_rows) != int(job["step_count"]):
|
||||
raise JobExportError(
|
||||
"Job step count mismatch: "
|
||||
f"expected {job['step_count']}, "
|
||||
f"calculated {len(step_rows)}"
|
||||
)
|
||||
|
||||
created_at = self._parse_utc(
|
||||
str(job["created_at"])
|
||||
)
|
||||
|
||||
local_created_at = created_at.astimezone(
|
||||
self._timezone
|
||||
)
|
||||
|
||||
zip_filename = (
|
||||
"JOB-"
|
||||
+ local_created_at.strftime(
|
||||
"%d-%m-%Y_%H%M%S"
|
||||
)
|
||||
+ "-"
|
||||
+ str(job["id"]).removeprefix("JOB-")
|
||||
+ ".zip"
|
||||
)
|
||||
|
||||
job_json = {
|
||||
"job_id": str(job["id"]),
|
||||
"created_at": str(job["created_at"]),
|
||||
"method_id": str(job["method_id"]),
|
||||
"method_version": int(job["method_version"]),
|
||||
"hash_mode": hash_mode,
|
||||
"hash_file_name": str(job["hash_file_name"]),
|
||||
"hash_file_sha256": str(job["hash_file_sha256"]),
|
||||
"hash_count": int(job["hash_count"]),
|
||||
"handshake_count": len(handshake_rows),
|
||||
"step_count": int(job["step_count"]),
|
||||
"handshakes": [
|
||||
{
|
||||
"handshake_id": int(row["handshake_id"]),
|
||||
"hash22000": str(row["hash22000"]),
|
||||
"access_point_id": (
|
||||
int(row["access_point_id"])
|
||||
if row["access_point_id"] is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
for row in handshake_rows
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"step_no": int(row["step_no"]),
|
||||
"step_id": str(row["step_id"]),
|
||||
"session_name": str(row["session_name"]),
|
||||
"definition": json.loads(
|
||||
row["definition_json"]
|
||||
),
|
||||
}
|
||||
for row in step_rows
|
||||
],
|
||||
}
|
||||
|
||||
job_bytes = self._json_bytes(job_json)
|
||||
hash_bytes = hash_content.encode("utf-8")
|
||||
|
||||
manifest_files = [
|
||||
{
|
||||
"path": "job.json",
|
||||
"size": len(job_bytes),
|
||||
"sha256": hashlib.sha256(
|
||||
job_bytes
|
||||
).hexdigest(),
|
||||
},
|
||||
{
|
||||
"path": f"hashes/{job['hash_file_name']}",
|
||||
"size": len(hash_bytes),
|
||||
"sha256": hashlib.sha256(
|
||||
hash_bytes
|
||||
).hexdigest(),
|
||||
},
|
||||
]
|
||||
|
||||
manifest = {
|
||||
"format_version": 1,
|
||||
"job_id": str(job["id"]),
|
||||
"created_at": str(job["created_at"]),
|
||||
"zip_filename": zip_filename,
|
||||
"files": manifest_files,
|
||||
}
|
||||
|
||||
manifest_bytes = self._json_bytes(
|
||||
manifest
|
||||
)
|
||||
|
||||
output_path = Path(output_dir)
|
||||
output_path.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True,
|
||||
)
|
||||
|
||||
final_path = output_path / zip_filename
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="wb",
|
||||
suffix=".zip",
|
||||
dir=output_path,
|
||||
delete=False,
|
||||
) as tmp:
|
||||
temporary_path = Path(tmp.name)
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(
|
||||
temporary_path,
|
||||
mode="w",
|
||||
compression=zipfile.ZIP_DEFLATED,
|
||||
) as archive:
|
||||
archive.writestr(
|
||||
"job.json",
|
||||
job_bytes,
|
||||
)
|
||||
archive.writestr(
|
||||
f"hashes/{job['hash_file_name']}",
|
||||
hash_bytes,
|
||||
)
|
||||
archive.writestr(
|
||||
"manifest.json",
|
||||
manifest_bytes,
|
||||
)
|
||||
|
||||
temporary_path.replace(final_path)
|
||||
|
||||
except Exception:
|
||||
temporary_path.unlink(
|
||||
missing_ok=True
|
||||
)
|
||||
raise
|
||||
|
||||
return final_path
|
||||
@@ -0,0 +1,262 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from cracklab.server.repositories.audit import AuditRepository
|
||||
from cracklab.server.repositories.handshakes import HandshakesRepository
|
||||
from cracklab.server.repositories.jobs import JobsRepository
|
||||
from cracklab.server.repositories.methods import MethodsRepository
|
||||
from cracklab.server.repositories.reservations import ReservationsRepository
|
||||
from cracklab.server.services.method_definition import (
|
||||
MethodDefinitionError,
|
||||
validate_method_definition,
|
||||
)
|
||||
|
||||
|
||||
class JobIssuanceError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IssuedJob:
|
||||
job_id: str
|
||||
method_id: str
|
||||
method_version: int
|
||||
status: str
|
||||
created_at: str
|
||||
hash_file_name: str
|
||||
hash_file_sha256: str
|
||||
hash_count: int
|
||||
handshake_count: int
|
||||
step_count: int
|
||||
hash_content: str
|
||||
handshake_ids: tuple[int, ...]
|
||||
steps: tuple[dict[str, Any], ...]
|
||||
|
||||
|
||||
class JobIssuanceService:
|
||||
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||
self._conn = conn
|
||||
self._methods = MethodsRepository(conn)
|
||||
self._handshakes = HandshakesRepository(conn)
|
||||
self._jobs = JobsRepository(conn)
|
||||
self._reservations = ReservationsRepository(conn)
|
||||
self._audit = AuditRepository(conn)
|
||||
|
||||
def issue_job(
|
||||
self,
|
||||
method_id: str,
|
||||
method_version: int,
|
||||
limit: int,
|
||||
client_id: str | None = None,
|
||||
) -> IssuedJob | None:
|
||||
if limit <= 0:
|
||||
raise ValueError("limit must be greater than zero")
|
||||
|
||||
method_version_row = self._methods.get_method_version(
|
||||
method_id,
|
||||
method_version,
|
||||
)
|
||||
if method_version_row is None:
|
||||
raise JobIssuanceError(
|
||||
f"Method version not found: {method_id} v{method_version}"
|
||||
)
|
||||
|
||||
definition = self._decode_method_definition(
|
||||
method_version_row["definition_json"],
|
||||
)
|
||||
steps = self._build_steps(
|
||||
method_id,
|
||||
method_version,
|
||||
definition,
|
||||
)
|
||||
|
||||
self._conn.execute("BEGIN IMMEDIATE")
|
||||
|
||||
try:
|
||||
handshake_rows = self._handshakes.list_eligible(
|
||||
method_id,
|
||||
method_version,
|
||||
limit,
|
||||
)
|
||||
|
||||
if not handshake_rows:
|
||||
self._conn.rollback()
|
||||
return None
|
||||
|
||||
job_id = f"JOB-{uuid.uuid4().hex[:12].upper()}"
|
||||
hash_file_name = f"{job_id}.22000"
|
||||
|
||||
handshake_ids = tuple(
|
||||
int(row["handshake_id"])
|
||||
for row in handshake_rows
|
||||
)
|
||||
|
||||
unique_hashes: list[str] = []
|
||||
seen_hashes: set[str] = set()
|
||||
|
||||
for row in handshake_rows:
|
||||
hash22000 = str(row["hash22000"]).strip()
|
||||
|
||||
if hash22000 not in seen_hashes:
|
||||
seen_hashes.add(hash22000)
|
||||
unique_hashes.append(hash22000)
|
||||
|
||||
hash_content = "\n".join(unique_hashes) + "\n"
|
||||
hash_file_sha256 = hashlib.sha256(
|
||||
hash_content.encode("utf-8"),
|
||||
).hexdigest()
|
||||
|
||||
created_at = self._jobs.create_job(
|
||||
job_id=job_id,
|
||||
method_id=method_id,
|
||||
method_version=method_version,
|
||||
status="QUEUED",
|
||||
hash_file_name=hash_file_name,
|
||||
hash_file_sha256=hash_file_sha256,
|
||||
hash_count=len(unique_hashes),
|
||||
step_count=len(steps),
|
||||
client_id=client_id,
|
||||
)
|
||||
|
||||
for row in handshake_rows:
|
||||
self._jobs.add_job_handshake(
|
||||
job_id=job_id,
|
||||
handshake_id=int(row["handshake_id"]),
|
||||
hash22000=str(row["hash22000"]).strip(),
|
||||
access_point_id=(
|
||||
int(row["access_point_id"])
|
||||
if row["access_point_id"] is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
self._reservations.create_reservation(
|
||||
reservation_id=uuid.uuid4().hex,
|
||||
handshake_id=int(row["handshake_id"]),
|
||||
method_id=method_id,
|
||||
method_version=method_version,
|
||||
job_id=job_id,
|
||||
client_id=client_id,
|
||||
)
|
||||
|
||||
for step_no, step in enumerate(steps, start=1):
|
||||
self._jobs.create_job_step(
|
||||
job_id=job_id,
|
||||
step_no=step_no,
|
||||
step_id=step["step_id"],
|
||||
status="PENDING",
|
||||
session_name=self._session_name(
|
||||
job_id,
|
||||
step["step_id"],
|
||||
),
|
||||
definition=step,
|
||||
)
|
||||
|
||||
self._audit.create_event(
|
||||
event_type="JOB_ISSUED",
|
||||
job_id=job_id,
|
||||
client_id=client_id,
|
||||
method_id=method_id,
|
||||
method_version=method_version,
|
||||
details={
|
||||
"handshake_count": len(handshake_rows),
|
||||
"hash_count": len(unique_hashes),
|
||||
"step_count": len(steps),
|
||||
"hash_file_name": hash_file_name,
|
||||
},
|
||||
)
|
||||
|
||||
self._conn.commit()
|
||||
|
||||
except Exception:
|
||||
self._conn.rollback()
|
||||
raise
|
||||
|
||||
return IssuedJob(
|
||||
job_id=job_id,
|
||||
method_id=method_id,
|
||||
method_version=method_version,
|
||||
status="QUEUED",
|
||||
created_at=created_at,
|
||||
hash_file_name=hash_file_name,
|
||||
hash_file_sha256=hash_file_sha256,
|
||||
hash_count=len(unique_hashes),
|
||||
handshake_count=len(handshake_rows),
|
||||
step_count=len(steps),
|
||||
hash_content=hash_content,
|
||||
handshake_ids=handshake_ids,
|
||||
steps=tuple(steps),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _decode_method_definition(
|
||||
definition_json: str,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
definition = json.loads(definition_json)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise JobIssuanceError(
|
||||
"Method version contains invalid definition JSON"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
return validate_method_definition(definition)
|
||||
except MethodDefinitionError as exc:
|
||||
raise JobIssuanceError(str(exc)) from exc
|
||||
|
||||
@staticmethod
|
||||
def _build_steps(
|
||||
method_id: str,
|
||||
method_version: int,
|
||||
definition: dict[str, Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
del method_id
|
||||
del method_version
|
||||
|
||||
source_steps = definition["steps"]
|
||||
result: list[dict[str, Any]] = []
|
||||
seen_step_ids: set[str] = set()
|
||||
|
||||
for step in source_steps:
|
||||
if not isinstance(step, dict):
|
||||
raise JobIssuanceError(
|
||||
"Each method step must be a JSON object"
|
||||
)
|
||||
|
||||
step_id = step.get("step_id")
|
||||
|
||||
if not isinstance(step_id, str) or not step_id:
|
||||
raise JobIssuanceError(
|
||||
"Each method step must have a non-empty step_id"
|
||||
)
|
||||
|
||||
if step_id in seen_step_ids:
|
||||
raise JobIssuanceError(
|
||||
f"Duplicate step_id: {step_id}"
|
||||
)
|
||||
|
||||
seen_step_ids.add(step_id)
|
||||
|
||||
attack_mode = step.get("attack_mode")
|
||||
|
||||
if not isinstance(attack_mode, int) or attack_mode < 0:
|
||||
raise JobIssuanceError(
|
||||
f"Invalid attack_mode for step: {step_id}"
|
||||
)
|
||||
|
||||
result.append(dict(step))
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _session_name(
|
||||
job_id: str,
|
||||
step_id: str,
|
||||
) -> str:
|
||||
return f"cl_{job_id[:12]}_{step_id}"
|
||||
@@ -0,0 +1,740 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import tempfile
|
||||
import zipfile
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from cracklab.server.repositories.attempts import AttemptsRepository
|
||||
from cracklab.server.repositories.audit import AuditRepository
|
||||
from cracklab.server.repositories.credentials import CredentialsRepository
|
||||
from cracklab.server.repositories.jobs import JobsRepository
|
||||
from cracklab.server.repositories.reservations import ReservationsRepository
|
||||
from cracklab.server.repositories.results import ResultsRepository
|
||||
|
||||
|
||||
class JobResultError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class JobResultService:
|
||||
def __init__(self, conn) -> None:
|
||||
self._conn = conn
|
||||
self._attempts = AttemptsRepository(conn)
|
||||
self._audit = AuditRepository(conn)
|
||||
self._credentials = CredentialsRepository(conn)
|
||||
self._jobs = JobsRepository(conn)
|
||||
self._reservations = ReservationsRepository(conn)
|
||||
self._results = ResultsRepository(conn)
|
||||
|
||||
def import_result(self, package_path: Path | str) -> dict[str, Any]:
|
||||
package = Path(package_path)
|
||||
|
||||
if not package.is_file():
|
||||
raise JobResultError(f"Result package not found: {package}")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="cracklab-result-") as tmp:
|
||||
root = Path(tmp)
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(package, "r") as zf:
|
||||
self._safe_extract(zf, root)
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise JobResultError(
|
||||
f"Invalid result package: {package}"
|
||||
) from exc
|
||||
|
||||
result_path, show_path = self._find_result_files(root)
|
||||
result = self._read_json(result_path)
|
||||
|
||||
self._validate_result_sha256(result)
|
||||
|
||||
job_id = result.get("job_id")
|
||||
if not isinstance(job_id, str) or not job_id:
|
||||
raise JobResultError("Result is missing job_id")
|
||||
|
||||
self._conn.execute("BEGIN IMMEDIATE")
|
||||
|
||||
try:
|
||||
job = self._jobs.get_job(job_id)
|
||||
|
||||
if job is None:
|
||||
raise JobResultError(f"Unknown Job: {job_id}")
|
||||
|
||||
idempotent = self._check_idempotency(job, result)
|
||||
|
||||
if idempotent:
|
||||
self._conn.rollback()
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"status": "ALREADY_IMPORTED",
|
||||
"result_sha256": result["result_sha256"],
|
||||
}
|
||||
|
||||
self._validate_job_contract(job, result)
|
||||
|
||||
job_handshakes = self._load_job_handshakes(job_id)
|
||||
|
||||
unique_hashes = {
|
||||
row["hash22000"]
|
||||
for row in job_handshakes
|
||||
}
|
||||
|
||||
if len(unique_hashes) != job["hash_count"]:
|
||||
raise JobResultError(
|
||||
"Job hash_count does not match unique "
|
||||
"crack_job_handshakes hash22000 values"
|
||||
)
|
||||
|
||||
found = self._parse_show_file(
|
||||
show_path,
|
||||
job_handshakes,
|
||||
)
|
||||
|
||||
credential_stats = self._import_credentials(
|
||||
found,
|
||||
job_handshakes,
|
||||
)
|
||||
|
||||
attempt_count = self._register_attempts(
|
||||
job,
|
||||
result,
|
||||
job_handshakes,
|
||||
)
|
||||
|
||||
self._update_steps(job_id, result["steps"])
|
||||
|
||||
released = self._release_reservations(job_id)
|
||||
|
||||
imported_at = self._now()
|
||||
|
||||
self._jobs.update_job_status(
|
||||
job_id,
|
||||
"COMPLETED",
|
||||
)
|
||||
|
||||
self._jobs.update_job_result(
|
||||
job_id,
|
||||
result_sha256=result["result_sha256"],
|
||||
imported_at=imported_at,
|
||||
)
|
||||
|
||||
self._results.create_result(
|
||||
job_id,
|
||||
"COMPLETED",
|
||||
result,
|
||||
imported_at=imported_at,
|
||||
)
|
||||
|
||||
self._audit.create_event(
|
||||
event_type="JOB_RESULT_IMPORTED",
|
||||
job_id=job_id,
|
||||
client_id=job["client_id"],
|
||||
method_id=job["method_id"],
|
||||
method_version=job["method_version"],
|
||||
details={
|
||||
"result_sha256": result["result_sha256"],
|
||||
"credentials_imported": credential_stats["imported"],
|
||||
"credentials_already_present": (
|
||||
credential_stats["already_present"]
|
||||
),
|
||||
"attempts_completed": attempt_count,
|
||||
"reservations_released": released,
|
||||
},
|
||||
)
|
||||
|
||||
self._conn.commit()
|
||||
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"status": "COMPLETED",
|
||||
"result_sha256": result["result_sha256"],
|
||||
"credentials_imported": credential_stats["imported"],
|
||||
"credentials_already_present": (
|
||||
credential_stats["already_present"]
|
||||
),
|
||||
"attempts_completed": attempt_count,
|
||||
"reservations_released": released,
|
||||
}
|
||||
|
||||
except Exception:
|
||||
self._conn.rollback()
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _now() -> str:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime.now(timezone.utc).isoformat(
|
||||
timespec="milliseconds"
|
||||
).replace("+00:00", "Z")
|
||||
|
||||
@staticmethod
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise JobResultError(
|
||||
f"Invalid result.json: {path}"
|
||||
) from exc
|
||||
|
||||
if not isinstance(value, dict):
|
||||
raise JobResultError("result.json must contain a JSON object")
|
||||
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _safe_extract(
|
||||
zf: zipfile.ZipFile,
|
||||
destination: Path,
|
||||
) -> None:
|
||||
destination = destination.resolve()
|
||||
|
||||
for member in zf.infolist():
|
||||
target = (destination / member.filename).resolve()
|
||||
|
||||
try:
|
||||
target.relative_to(destination)
|
||||
except ValueError as exc:
|
||||
raise JobResultError(
|
||||
f"Unsafe path in result package: {member.filename}"
|
||||
) from exc
|
||||
|
||||
if member.is_dir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with zf.open(member) as src, target.open("wb") as dst:
|
||||
while True:
|
||||
chunk = src.read(1024 * 1024)
|
||||
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
dst.write(chunk)
|
||||
|
||||
@staticmethod
|
||||
def _find_result_files(
|
||||
root: Path,
|
||||
) -> tuple[Path, Path]:
|
||||
manifests = list(root.rglob("result.json"))
|
||||
|
||||
if len(manifests) != 1:
|
||||
raise JobResultError(
|
||||
"Result package must contain exactly one result.json"
|
||||
)
|
||||
|
||||
show_files = list(root.rglob("hashcat-show.txt"))
|
||||
|
||||
if len(show_files) != 1:
|
||||
raise JobResultError(
|
||||
"Result package must contain exactly one hashcat-show.txt"
|
||||
)
|
||||
|
||||
return manifests[0], show_files[0]
|
||||
|
||||
@staticmethod
|
||||
def _validate_result_sha256(result: dict[str, Any]) -> None:
|
||||
expected = result.get("result_sha256")
|
||||
|
||||
if not isinstance(expected, str) or not expected:
|
||||
raise JobResultError(
|
||||
"Result is missing result_sha256"
|
||||
)
|
||||
|
||||
payload = dict(result)
|
||||
payload.pop("result_sha256", None)
|
||||
|
||||
payload_bytes = json.dumps(
|
||||
payload,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
).encode("utf-8")
|
||||
|
||||
actual = hashlib.sha256(payload_bytes).hexdigest()
|
||||
|
||||
if actual != expected:
|
||||
raise JobResultError(
|
||||
"Invalid result_sha256: "
|
||||
f"expected {expected}, calculated {actual}"
|
||||
)
|
||||
|
||||
def _check_idempotency(
|
||||
self,
|
||||
job,
|
||||
result: dict[str, Any],
|
||||
) -> bool:
|
||||
result_sha256 = result["result_sha256"]
|
||||
|
||||
stored_job_sha = job["result_sha256"]
|
||||
stored_result = self._results.get_result(job["id"])
|
||||
|
||||
if stored_job_sha is not None:
|
||||
if stored_job_sha != result_sha256:
|
||||
raise JobResultError(
|
||||
f"Job {job['id']} already has a different result_sha256"
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
if stored_result is not None:
|
||||
stored_payload = self._results.decode_result(stored_result)
|
||||
stored_sha = stored_payload.get("result_sha256")
|
||||
|
||||
if stored_sha != result_sha256:
|
||||
raise JobResultError(
|
||||
f"Job {job['id']} already has a different imported result"
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
if job["imported_at"] is not None:
|
||||
raise JobResultError(
|
||||
f"Job {job['id']} is marked imported without a stored result"
|
||||
)
|
||||
|
||||
if job["status"] == "COMPLETED":
|
||||
raise JobResultError(
|
||||
f"Job {job['id']} is already COMPLETED without an imported result"
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
def _validate_job_contract(
|
||||
self,
|
||||
job,
|
||||
result: dict[str, Any],
|
||||
) -> None:
|
||||
if result.get("schema_version") != 1:
|
||||
raise JobResultError(
|
||||
"Unsupported result schema_version: "
|
||||
f"{result.get('schema_version')!r}"
|
||||
)
|
||||
|
||||
if result.get("job_id") != job["id"]:
|
||||
raise JobResultError(
|
||||
"Result Job ID does not match the database Job"
|
||||
)
|
||||
|
||||
if result.get("status") != "COMPLETED":
|
||||
raise JobResultError(
|
||||
"Only COMPLETED results can be imported"
|
||||
)
|
||||
|
||||
if result.get("hash_file_sha256") != job["hash_file_sha256"]:
|
||||
raise JobResultError(
|
||||
"Result hash_file_sha256 does not match the Job"
|
||||
)
|
||||
|
||||
if result.get("hash_count") != job["hash_count"]:
|
||||
raise JobResultError(
|
||||
"Result hash_count does not match the Job"
|
||||
)
|
||||
|
||||
method = result.get("method")
|
||||
|
||||
if not isinstance(method, dict):
|
||||
raise JobResultError(
|
||||
"Result is missing method object"
|
||||
)
|
||||
|
||||
if method.get("id") != job["method_id"]:
|
||||
raise JobResultError(
|
||||
"Result method id does not match the Job"
|
||||
)
|
||||
|
||||
if method.get("version") != job["method_version"]:
|
||||
raise JobResultError(
|
||||
"Result method version does not match the Job"
|
||||
)
|
||||
|
||||
completed_at = result.get("completed_at")
|
||||
|
||||
if not isinstance(completed_at, str) or not completed_at:
|
||||
raise JobResultError(
|
||||
"Result is missing completed_at"
|
||||
)
|
||||
|
||||
result_steps = result.get("steps")
|
||||
|
||||
if not isinstance(result_steps, list):
|
||||
raise JobResultError(
|
||||
"Result is missing steps list"
|
||||
)
|
||||
|
||||
if len(result_steps) != job["step_count"]:
|
||||
raise JobResultError(
|
||||
"Result step count does not match the Job"
|
||||
)
|
||||
|
||||
db_steps = self._jobs.list_job_steps(job["id"])
|
||||
|
||||
if len(db_steps) != job["step_count"]:
|
||||
raise JobResultError(
|
||||
"Database Job step count does not match crack_job_steps"
|
||||
)
|
||||
|
||||
db_by_no = {
|
||||
row["step_no"]: row
|
||||
for row in db_steps
|
||||
}
|
||||
|
||||
seen_step_numbers: set[int] = set()
|
||||
|
||||
for step in result_steps:
|
||||
if not isinstance(step, dict):
|
||||
raise JobResultError(
|
||||
"Each result step must be an object"
|
||||
)
|
||||
|
||||
step_no = step.get("step_no")
|
||||
|
||||
if not isinstance(step_no, int):
|
||||
raise JobResultError(
|
||||
f"Invalid result step_no: {step_no!r}"
|
||||
)
|
||||
|
||||
if step_no in seen_step_numbers:
|
||||
raise JobResultError(
|
||||
f"Duplicate result step_no: {step_no}"
|
||||
)
|
||||
|
||||
seen_step_numbers.add(step_no)
|
||||
|
||||
db_step = db_by_no.get(step_no)
|
||||
|
||||
if db_step is None:
|
||||
raise JobResultError(
|
||||
f"Result contains unknown step_no: {step_no}"
|
||||
)
|
||||
|
||||
if step.get("step_id") != db_step["step_id"]:
|
||||
raise JobResultError(
|
||||
f"Step {step_no}: step_id does not match the Job"
|
||||
)
|
||||
|
||||
if step.get("session") != db_step["session_name"]:
|
||||
raise JobResultError(
|
||||
f"Step {step_no}: session does not match the Job"
|
||||
)
|
||||
|
||||
if step.get("status") != "COMPLETED":
|
||||
raise JobResultError(
|
||||
f"Step {step_no}: result status must be COMPLETED"
|
||||
)
|
||||
|
||||
def _load_job_handshakes(
|
||||
self,
|
||||
job_id: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
rows = self._jobs.list_job_handshakes(job_id)
|
||||
|
||||
if not rows:
|
||||
raise JobResultError(
|
||||
f"Job {job_id} contains no handshakes"
|
||||
)
|
||||
|
||||
result = []
|
||||
|
||||
for row in rows:
|
||||
result.append(
|
||||
{
|
||||
"handshake_id": row["handshake_id"],
|
||||
"hash22000": row["hash22000"],
|
||||
"access_point_id": row["access_point_id"],
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _normalize_mac(value: str) -> str:
|
||||
normalized = value.strip().lower().replace(":", "").replace("-", "")
|
||||
|
||||
if len(normalized) != 12:
|
||||
raise JobResultError(
|
||||
f"Invalid MAC address: {value!r}"
|
||||
)
|
||||
|
||||
try:
|
||||
int(normalized, 16)
|
||||
except ValueError as exc:
|
||||
raise JobResultError(
|
||||
f"Invalid MAC address: {value!r}"
|
||||
) from exc
|
||||
|
||||
return normalized
|
||||
|
||||
@classmethod
|
||||
def _hash_identity(
|
||||
cls,
|
||||
hash22000: str,
|
||||
) -> tuple[str, str, str]:
|
||||
parts = hash22000.split("*")
|
||||
|
||||
if len(parts) < 6 or parts[0] != "WPA":
|
||||
raise JobResultError(
|
||||
f"Unsupported hash22000 format: {hash22000}"
|
||||
)
|
||||
|
||||
if parts[1] not in {"01", "02"}:
|
||||
raise JobResultError(
|
||||
f"Unsupported WPA hash type: {parts[1]}"
|
||||
)
|
||||
|
||||
hash_value = parts[2].strip().lower()
|
||||
ap_mac = cls._normalize_mac(parts[3])
|
||||
client_mac = cls._normalize_mac(parts[4])
|
||||
|
||||
if not hash_value:
|
||||
raise JobResultError(
|
||||
f"Invalid hash22000 hash value: {hash22000}"
|
||||
)
|
||||
|
||||
return (
|
||||
hash_value,
|
||||
ap_mac,
|
||||
client_mac,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _parse_show_identity(
|
||||
cls,
|
||||
line: str,
|
||||
) -> tuple[str, str, str, str]:
|
||||
fields = line.split(":", 4)
|
||||
|
||||
if len(fields) != 5:
|
||||
raise JobResultError(
|
||||
f"Invalid Hashcat --show line: {line}"
|
||||
)
|
||||
|
||||
hash_value = fields[0].strip().lower()
|
||||
ap_mac = cls._normalize_mac(fields[1])
|
||||
client_mac = cls._normalize_mac(fields[2])
|
||||
password = fields[4]
|
||||
|
||||
if not hash_value:
|
||||
raise JobResultError(
|
||||
f"Invalid Hashcat --show hash: {line}"
|
||||
)
|
||||
|
||||
return (
|
||||
hash_value,
|
||||
ap_mac,
|
||||
client_mac,
|
||||
password,
|
||||
)
|
||||
|
||||
def _parse_show_file(
|
||||
self,
|
||||
path: Path,
|
||||
job_handshakes: list[dict[str, Any]],
|
||||
) -> list[tuple[str, str]]:
|
||||
identity_to_hashes: dict[
|
||||
tuple[str, str, str],
|
||||
list[str],
|
||||
] = defaultdict(list)
|
||||
|
||||
for row in job_handshakes:
|
||||
identity = self._hash_identity(row["hash22000"])
|
||||
hashes = identity_to_hashes[identity]
|
||||
|
||||
if row["hash22000"] not in hashes:
|
||||
hashes.append(row["hash22000"])
|
||||
|
||||
found: list[tuple[str, str]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
|
||||
for line_no, raw_line in enumerate(
|
||||
path.read_text(encoding="utf-8").splitlines(),
|
||||
start=1,
|
||||
):
|
||||
line = raw_line.strip()
|
||||
|
||||
if not line:
|
||||
continue
|
||||
|
||||
hash_value, ap_mac, client_mac, password = (
|
||||
self._parse_show_identity(line)
|
||||
)
|
||||
|
||||
identity = (
|
||||
hash_value,
|
||||
ap_mac,
|
||||
client_mac,
|
||||
)
|
||||
|
||||
hashes = identity_to_hashes.get(identity)
|
||||
|
||||
if not hashes:
|
||||
raise JobResultError(
|
||||
"Result contains an unknown Hashcat --show line "
|
||||
f"{line_no}: {line}"
|
||||
)
|
||||
|
||||
for hash22000 in hashes:
|
||||
item = (hash22000, password)
|
||||
|
||||
if item not in seen:
|
||||
seen.add(item)
|
||||
found.append(item)
|
||||
|
||||
return found
|
||||
|
||||
def _import_credentials(
|
||||
self,
|
||||
found: list[tuple[str, str]],
|
||||
job_handshakes: list[dict[str, Any]],
|
||||
) -> dict[str, int]:
|
||||
by_hash: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
|
||||
for row in job_handshakes:
|
||||
by_hash[row["hash22000"]].append(row)
|
||||
|
||||
imported = 0
|
||||
already_present = 0
|
||||
|
||||
for hash22000, password in found:
|
||||
mappings = by_hash.get(hash22000)
|
||||
|
||||
if not mappings:
|
||||
raise JobResultError(
|
||||
"Hashcat --show contains a hash that is not part "
|
||||
f"of the Job: {hash22000}"
|
||||
)
|
||||
|
||||
for mapping in mappings:
|
||||
handshake_id = mapping["handshake_id"]
|
||||
access_point_id = mapping["access_point_id"]
|
||||
|
||||
if access_point_id is None:
|
||||
raise JobResultError(
|
||||
"Cannot import credential for handshake "
|
||||
f"{handshake_id}: access_point_id is NULL"
|
||||
)
|
||||
|
||||
existing = self._credentials.get_credential(
|
||||
access_point_id,
|
||||
handshake_id,
|
||||
password,
|
||||
)
|
||||
|
||||
if existing is not None:
|
||||
already_present += 1
|
||||
self._credentials.mark_access_point_cracked(
|
||||
access_point_id,
|
||||
)
|
||||
continue
|
||||
|
||||
self._credentials.create_hashcat_credential(
|
||||
access_point_id,
|
||||
handshake_id,
|
||||
password,
|
||||
)
|
||||
imported += 1
|
||||
|
||||
return {
|
||||
"imported": imported,
|
||||
"already_present": already_present,
|
||||
}
|
||||
|
||||
def _register_attempts(
|
||||
self,
|
||||
job,
|
||||
result: dict[str, Any],
|
||||
job_handshakes: list[dict[str, Any]],
|
||||
) -> int:
|
||||
if result["status"] != "COMPLETED":
|
||||
return 0
|
||||
|
||||
completed_at = result["completed_at"]
|
||||
count = 0
|
||||
|
||||
for row in job_handshakes:
|
||||
existing = self._attempts.get_attempt(
|
||||
row["handshake_id"],
|
||||
job["method_id"],
|
||||
job["method_version"],
|
||||
)
|
||||
|
||||
if existing is not None:
|
||||
if (
|
||||
existing["job_id"] != job["id"]
|
||||
or existing["status"] != "COMPLETED"
|
||||
):
|
||||
raise JobResultError(
|
||||
"Handshake "
|
||||
f"{row['handshake_id']} already has a "
|
||||
"conflicting attempt for this method/version"
|
||||
)
|
||||
|
||||
continue
|
||||
|
||||
self._attempts.create_attempt(
|
||||
handshake_id=row["handshake_id"],
|
||||
method_id=job["method_id"],
|
||||
method_version=job["method_version"],
|
||||
job_id=job["id"],
|
||||
completed_at=completed_at,
|
||||
)
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
def _update_steps(
|
||||
self,
|
||||
job_id: str,
|
||||
result_steps: list[dict[str, Any]],
|
||||
) -> None:
|
||||
for step in result_steps:
|
||||
step_no = step["step_no"]
|
||||
|
||||
self._jobs.update_job_step(
|
||||
job_id=job_id,
|
||||
step_no=step_no,
|
||||
status="COMPLETED",
|
||||
started_at=step.get("started_at"),
|
||||
completed_at=step.get("completed_at"),
|
||||
exit_code=step.get("exit_code"),
|
||||
restore_seen=(
|
||||
1 if step.get("restore_seen") else 0
|
||||
),
|
||||
error=step.get("error"),
|
||||
)
|
||||
|
||||
updated = self._jobs.get_job_step(
|
||||
job_id,
|
||||
step_no,
|
||||
)
|
||||
|
||||
if updated is None:
|
||||
raise JobResultError(
|
||||
f"Job step disappeared during result import: "
|
||||
f"{step_no}"
|
||||
)
|
||||
|
||||
def _release_reservations(
|
||||
self,
|
||||
job_id: str,
|
||||
) -> int:
|
||||
released = 0
|
||||
|
||||
for reservation in self._reservations.list_job_reservations(
|
||||
job_id
|
||||
):
|
||||
if reservation["status"] != "ACTIVE":
|
||||
continue
|
||||
|
||||
self._reservations.update_status(
|
||||
reservation["id"],
|
||||
"RELEASED",
|
||||
)
|
||||
released += 1
|
||||
|
||||
return released
|
||||
@@ -0,0 +1,251 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
class MethodDefinitionError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
_RESOURCE_RE = re.compile(r"^[a-z][a-z0-9_-]*:[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||
_STEP_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||
_MASK_CHARSET_SIZES = {
|
||||
"?l": 26,
|
||||
"?u": 26,
|
||||
"?d": 10,
|
||||
"?s": 33,
|
||||
"?a": 95,
|
||||
"?b": 256,
|
||||
}
|
||||
_MASK_MIN_LENGTH = 8
|
||||
|
||||
|
||||
def validate_method_definition(
|
||||
definition: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(definition, dict):
|
||||
raise MethodDefinitionError(
|
||||
"Method definition must be a JSON object"
|
||||
)
|
||||
|
||||
if definition.get("schema_version") != 1:
|
||||
raise MethodDefinitionError(
|
||||
"Unsupported method definition schema version"
|
||||
)
|
||||
|
||||
hash_mode = definition.get("hash_mode")
|
||||
|
||||
if not isinstance(hash_mode, int) or isinstance(hash_mode, bool):
|
||||
raise MethodDefinitionError(
|
||||
"Method definition hash_mode must be a non-negative integer"
|
||||
)
|
||||
|
||||
if hash_mode < 0:
|
||||
raise MethodDefinitionError(
|
||||
"Method definition hash_mode must be a non-negative integer"
|
||||
)
|
||||
|
||||
steps = definition.get("steps")
|
||||
|
||||
if not isinstance(steps, list) or not steps:
|
||||
raise MethodDefinitionError(
|
||||
"Method definition must contain a non-empty steps list"
|
||||
)
|
||||
|
||||
seen_step_ids: set[str] = set()
|
||||
|
||||
for step in steps:
|
||||
_validate_step(step, seen_step_ids)
|
||||
|
||||
return definition
|
||||
|
||||
|
||||
def _validate_step(
|
||||
step: Any,
|
||||
seen_step_ids: set[str],
|
||||
) -> None:
|
||||
if not isinstance(step, dict):
|
||||
raise MethodDefinitionError(
|
||||
"Each method step must be a JSON object"
|
||||
)
|
||||
|
||||
step_id = step.get("step_id")
|
||||
|
||||
if not isinstance(step_id, str) or not _STEP_ID_RE.fullmatch(step_id):
|
||||
raise MethodDefinitionError(
|
||||
"Each method step must have a valid step_id"
|
||||
)
|
||||
|
||||
if step_id in seen_step_ids:
|
||||
raise MethodDefinitionError(
|
||||
f"Duplicate step_id: {step_id}"
|
||||
)
|
||||
|
||||
seen_step_ids.add(step_id)
|
||||
|
||||
attack_mode = step.get("attack_mode")
|
||||
|
||||
if not isinstance(attack_mode, int) or isinstance(attack_mode, bool):
|
||||
raise MethodDefinitionError(
|
||||
f"Invalid attack_mode for step: {step_id}"
|
||||
)
|
||||
|
||||
if attack_mode < 0:
|
||||
raise MethodDefinitionError(
|
||||
f"Invalid attack_mode for step: {step_id}"
|
||||
)
|
||||
|
||||
if attack_mode == 0:
|
||||
_require_dictionary(step, step_id)
|
||||
|
||||
elif attack_mode == 1:
|
||||
_require_dictionaries(step, step_id)
|
||||
|
||||
elif attack_mode == 3:
|
||||
_require_mask(
|
||||
step,
|
||||
step_id,
|
||||
minimum_length=_MASK_MIN_LENGTH,
|
||||
)
|
||||
|
||||
elif attack_mode == 6:
|
||||
_require_dictionary(step, step_id)
|
||||
_require_mask(
|
||||
step,
|
||||
step_id,
|
||||
minimum_length=_MASK_MIN_LENGTH,
|
||||
)
|
||||
|
||||
elif attack_mode == 7:
|
||||
_require_mask(
|
||||
step,
|
||||
step_id,
|
||||
minimum_length=_MASK_MIN_LENGTH,
|
||||
)
|
||||
_require_dictionary(step, step_id)
|
||||
|
||||
if "rules" in step:
|
||||
_validate_resource_object(
|
||||
step["rules"],
|
||||
"rules",
|
||||
step_id,
|
||||
)
|
||||
|
||||
|
||||
def _require_dictionary(
|
||||
step: dict[str, Any],
|
||||
step_id: str,
|
||||
) -> None:
|
||||
if "dictionary" not in step:
|
||||
raise MethodDefinitionError(
|
||||
f"Step {step_id} requires dictionary"
|
||||
)
|
||||
|
||||
_validate_resource_object(
|
||||
step["dictionary"],
|
||||
"dictionary",
|
||||
step_id,
|
||||
)
|
||||
|
||||
|
||||
def _require_dictionaries(
|
||||
step: dict[str, Any],
|
||||
step_id: str,
|
||||
) -> None:
|
||||
dictionaries = step.get("dictionaries")
|
||||
|
||||
if not isinstance(dictionaries, list) or len(dictionaries) != 2:
|
||||
raise MethodDefinitionError(
|
||||
f"Step {step_id} requires exactly two dictionaries"
|
||||
)
|
||||
|
||||
for dictionary in dictionaries:
|
||||
_validate_resource_object(
|
||||
dictionary,
|
||||
"dictionary",
|
||||
step_id,
|
||||
)
|
||||
|
||||
|
||||
def _require_mask(
|
||||
step: dict[str, Any],
|
||||
step_id: str,
|
||||
minimum_length: int | None = None,
|
||||
) -> None:
|
||||
mask = step.get("mask")
|
||||
|
||||
if not isinstance(mask, str) or not mask:
|
||||
raise MethodDefinitionError(
|
||||
f"Step {step_id} requires a non-empty mask"
|
||||
)
|
||||
|
||||
length = 0
|
||||
index = 0
|
||||
|
||||
while index < len(mask):
|
||||
if mask[index] != "?":
|
||||
length += 1
|
||||
index += 1
|
||||
continue
|
||||
|
||||
if index + 1 >= len(mask):
|
||||
raise MethodDefinitionError(
|
||||
f"Step {step_id} mask contains a lone '?' "
|
||||
f"at position {index + 1}"
|
||||
)
|
||||
|
||||
token = mask[index:index + 2]
|
||||
|
||||
if token == "??":
|
||||
length += 1
|
||||
index += 2
|
||||
continue
|
||||
|
||||
if token not in _MASK_CHARSET_SIZES:
|
||||
raise MethodDefinitionError(
|
||||
f"Step {step_id} contains unsupported mask token "
|
||||
f"{token!r} at position {index + 1}"
|
||||
)
|
||||
|
||||
length += 1
|
||||
index += 2
|
||||
|
||||
if (
|
||||
minimum_length is not None
|
||||
and length < minimum_length
|
||||
):
|
||||
raise MethodDefinitionError(
|
||||
f"Step {step_id} mask must produce at least "
|
||||
f"{minimum_length} characters; got {length}"
|
||||
)
|
||||
|
||||
def _validate_resource_object(
|
||||
value: Any,
|
||||
resource_type: str,
|
||||
step_id: str,
|
||||
) -> None:
|
||||
if not isinstance(value, dict):
|
||||
raise MethodDefinitionError(
|
||||
f"Step {step_id} {resource_type} must be an object"
|
||||
)
|
||||
|
||||
resource = value.get("resource")
|
||||
|
||||
if not isinstance(resource, str) or not _RESOURCE_RE.fullmatch(resource):
|
||||
raise MethodDefinitionError(
|
||||
f"Step {step_id} {resource_type}.resource must be "
|
||||
"a valid logical resource ID"
|
||||
)
|
||||
|
||||
expected_prefix = (
|
||||
"wordlist:"
|
||||
if resource_type == "dictionary"
|
||||
else f"{resource_type}:"
|
||||
)
|
||||
|
||||
if not resource.startswith(expected_prefix):
|
||||
raise MethodDefinitionError(
|
||||
f"Step {step_id} {resource_type}.resource must start "
|
||||
f"with {expected_prefix!r}"
|
||||
)
|
||||
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from cracklab.server.repositories.methods import MethodsRepository
|
||||
from cracklab.server.services.method_definition import (
|
||||
MethodDefinitionError,
|
||||
validate_method_definition,
|
||||
)
|
||||
|
||||
|
||||
class MethodServiceError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class MethodService:
|
||||
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||
self._methods = MethodsRepository(conn)
|
||||
|
||||
def create_method(
|
||||
self,
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
method_id: str | None = None,
|
||||
) -> str:
|
||||
if not name or not name.strip():
|
||||
raise MethodServiceError("Method name must not be empty")
|
||||
|
||||
if method_id is None:
|
||||
method_id = f"MTH-{uuid.uuid4().hex}"
|
||||
|
||||
if not method_id:
|
||||
raise MethodServiceError("Method ID must not be empty")
|
||||
|
||||
self._methods.create_method(
|
||||
method_id=method_id,
|
||||
name=name.strip(),
|
||||
description=description.strip() if description else None,
|
||||
)
|
||||
|
||||
return method_id
|
||||
|
||||
def create_method_version(
|
||||
self,
|
||||
method_id: str,
|
||||
version: int,
|
||||
definition: dict[str, Any],
|
||||
) -> None:
|
||||
if version <= 0:
|
||||
raise MethodServiceError(
|
||||
"Method version must be greater than zero"
|
||||
)
|
||||
|
||||
try:
|
||||
validate_method_definition(definition)
|
||||
except MethodDefinitionError as exc:
|
||||
raise MethodServiceError(str(exc)) from exc
|
||||
|
||||
self._methods.create_method_version(
|
||||
method_id=method_id,
|
||||
version=version,
|
||||
definition=definition,
|
||||
)
|
||||
|
||||
def create_next_method_version(
|
||||
self,
|
||||
method_id: str,
|
||||
definition: dict[str, Any],
|
||||
) -> int:
|
||||
try:
|
||||
validate_method_definition(definition)
|
||||
except MethodDefinitionError as exc:
|
||||
raise MethodServiceError(str(exc)) from exc
|
||||
|
||||
version = self._methods.get_next_method_version(method_id)
|
||||
|
||||
self._methods.create_method_version(
|
||||
method_id=method_id,
|
||||
version=version,
|
||||
definition=definition,
|
||||
)
|
||||
|
||||
return version
|
||||
|
||||
def get_method(self, method_id: str) -> sqlite3.Row | None:
|
||||
return self._methods.get_method(method_id)
|
||||
|
||||
def list_methods(self) -> list[sqlite3.Row]:
|
||||
return self._methods.list_methods()
|
||||
|
||||
def get_method_version(
|
||||
self,
|
||||
method_id: str,
|
||||
version: int,
|
||||
) -> sqlite3.Row | None:
|
||||
return self._methods.get_method_version(
|
||||
method_id,
|
||||
version,
|
||||
)
|
||||
|
||||
def list_method_versions(
|
||||
self,
|
||||
method_id: str,
|
||||
) -> list[sqlite3.Row]:
|
||||
return self._methods.list_method_versions(method_id)
|
||||
|
||||
def get_latest_method_version(
|
||||
self,
|
||||
method_id: str,
|
||||
) -> sqlite3.Row | None:
|
||||
versions = self._methods.list_method_versions(method_id)
|
||||
|
||||
if not versions:
|
||||
return None
|
||||
|
||||
return versions[-1]
|
||||
@@ -0,0 +1,914 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from cracklab.server.services.methods import MethodService
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StockMethod:
|
||||
method_id: str
|
||||
name: str
|
||||
description: str
|
||||
definition: dict[str, Any] | None
|
||||
|
||||
|
||||
STOCK_METHODS = (
|
||||
StockMethod(
|
||||
method_id="mobile_oper_spb_plus_79",
|
||||
name="MobileOperSpb+79",
|
||||
description="Перебор мобильных номеров Санкт-Петербурга с префиксом +79.",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"hash_mode": 22000,
|
||||
"steps": [
|
||||
{
|
||||
"step_id": f"mask-{index:03d}",
|
||||
"attack_mode": 3,
|
||||
"mask": mask,
|
||||
}
|
||||
for index, mask in enumerate(
|
||||
(
|
||||
"+791100?d?d?d?d?d",
|
||||
"+791101?d?d?d?d?d",
|
||||
"+791102?d?d?d?d?d",
|
||||
"+791103?d?d?d?d?d",
|
||||
"+791108?d?d?d?d?d",
|
||||
"+791109?d?d?d?d?d",
|
||||
"+791110?d?d?d?d?d",
|
||||
"+791111?d?d?d?d?d",
|
||||
"+791112?d?d?d?d?d",
|
||||
"+791117?d?d?d?d?d",
|
||||
"+791119?d?d?d?d?d",
|
||||
"+79811?d?d?d?d?d?d",
|
||||
"+79817?d?d?d?d?d?d",
|
||||
"+79818?d?d?d?d?d?d",
|
||||
"+79819?d?d?d?d?d?d",
|
||||
"+798986?d?d?d?d?d",
|
||||
"+798686?d?d?d?d?d",
|
||||
"+798687?d?d?d?d?d",
|
||||
"+798688?d?d?d?d?d",
|
||||
"+792118?d?d?d?d?d",
|
||||
"+792119?d?d?d?d?d",
|
||||
"+79213?d?d?d?d?d?d",
|
||||
"+79214?d?d?d?d?d?d",
|
||||
"+79215?d?d?d?d?d?d",
|
||||
"+79216?d?d?d?d?d?d",
|
||||
"+79217?d?d?d?d?d?d",
|
||||
"+79218?d?d?d?d?d?d",
|
||||
"+79219?d?d?d?d?d?d",
|
||||
"+793110?d?d?d?d?d",
|
||||
"+793111?d?d?d?d?d",
|
||||
"+793112?d?d?d?d?d",
|
||||
"+793113?d?d?d?d?d",
|
||||
"+79312?d?d?d?d?d?d",
|
||||
"+79313?d?d?d?d?d?d",
|
||||
"+79315?d?d?d?d?d?d",
|
||||
"+79319?d?d?d?d?d?d",
|
||||
"+79291?d?d?d?d?d?d",
|
||||
"+799923?d?d?d?d?d",
|
||||
"+799924?d?d?d?d?d",
|
||||
"+799925?d?d?d?d?d",
|
||||
"+7963241?d?d?d?d",
|
||||
"+7963242?d?d?d?d",
|
||||
"+7963243?d?d?d?d",
|
||||
"+7963244?d?d?d?d",
|
||||
"+7963245?d?d?d?d",
|
||||
"+7963246?d?d?d?d",
|
||||
"+7963247?d?d?d?d",
|
||||
"+7963248?d?d?d?d",
|
||||
"+7963249?d?d?d?d",
|
||||
"+796330?d?d?d?d?d",
|
||||
"+796331?d?d?d?d?d",
|
||||
"+796332?d?d?d?d?d",
|
||||
"+796334?d?d?d?d?d",
|
||||
"+79650?d?d?d?d?d?d",
|
||||
"+79651?d?d?d?d?d?d",
|
||||
"+79652?d?d?d?d?d?d",
|
||||
"+79653?d?d?d?d?d?d",
|
||||
"+79654?d?d?d?d?d?d",
|
||||
"+79655?d?d?d?d?d?d",
|
||||
"+79656?d?d?d?d?d?d",
|
||||
"+79657?d?d?d?d?d?d",
|
||||
"+79658?d?d?d?d?d?d",
|
||||
"+79659?d?d?d?d?d?d",
|
||||
"+790520?d?d?d?d?d",
|
||||
"+790521?d?d?d?d?d",
|
||||
"+790522?d?d?d?d?d",
|
||||
"+790523?d?d?d?d?d",
|
||||
"+790524?d?d?d?d?d",
|
||||
"+790525?d?d?d?d?d",
|
||||
"+790526?d?d?d?d?d",
|
||||
"+790527?d?d?d?d?d",
|
||||
"+790528?d?d?d?d?d",
|
||||
"+790624?d?d?d?d?d",
|
||||
"+790625?d?d?d?d?d",
|
||||
"+790626?d?d?d?d?d",
|
||||
"+790627?d?d?d?d?d",
|
||||
"+790958?d?d?d?d?d",
|
||||
"+790959?d?d?d?d?d",
|
||||
"+796023?d?d?d?d?d",
|
||||
"+796024?d?d?d?d?d",
|
||||
"+796025?d?d?d?d?d",
|
||||
"+796026?d?d?d?d?d",
|
||||
"+796027?d?d?d?d?d",
|
||||
"+796028?d?d?d?d?d",
|
||||
"+796434?d?d?d?d?d",
|
||||
"+79500?d?d?d?d?d?d",
|
||||
"+79502?d?d?d?d?d?d",
|
||||
"+79516?d?d?d?d?d?d",
|
||||
"+79517?d?d?d?d?d?d",
|
||||
"+79518?d?d?d?d?d?d",
|
||||
"+79522?d?d?d?d?d?d",
|
||||
"+79523?d?d?d?d?d?d",
|
||||
"+79531?d?d?d?d?d?d",
|
||||
"+79533?d?d?d?d?d?d",
|
||||
"+790433?d?d?d?d?d",
|
||||
"+790451?d?d?d?d?d",
|
||||
"+790455?d?d?d?d?d",
|
||||
"+790460?d?d?d?d?d",
|
||||
"+790461?d?d?d?d?d",
|
||||
"+790462?d?d?d?d?d",
|
||||
"+790463?d?d?d?d?d",
|
||||
"+790464?d?d?d?d?d",
|
||||
"+799100?d?d?d?d?d",
|
||||
"+799101?d?d?d?d?d",
|
||||
"+799102?d?d?d?d?d",
|
||||
"+790062?d?d?d?d?d",
|
||||
"+790063?d?d?d?d?d",
|
||||
"+790064?d?d?d?d?d",
|
||||
"+790065?d?d?d?d?d",
|
||||
"+799900?d?d?d?d?d",
|
||||
"+799901?d?d?d?d?d",
|
||||
"+799902?d?d?d?d?d",
|
||||
"+799903?d?d?d?d?d",
|
||||
"+799904?d?d?d?d?d",
|
||||
"+799909?d?d?d?d?d",
|
||||
"+79991?d?d?d?d?d?d",
|
||||
"+79992?d?d?d?d?d?d",
|
||||
"+799322?d?d?d?d?d",
|
||||
"+799343?d?d?d?d?d",
|
||||
"+799344?d?d?d?d?d",
|
||||
"+799555?d?d?d?d?d",
|
||||
"+799559?d?d?d?d?d",
|
||||
"+799560?d?d?d?d?d",
|
||||
"+799590?d?d?d?d?d",
|
||||
"+795840?d?d?d?d?d",
|
||||
"+795856?d?d?d?d?d",
|
||||
"+799511?d?d?d?d?d",
|
||||
"+799512?d?d?d?d?d",
|
||||
"+7999944?d?d?d?d",
|
||||
"+7999945?d?d?d?d",
|
||||
"+7999946?d?d?d?d",
|
||||
"+7999947?d?d?d?d",
|
||||
),
|
||||
start=1,
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
StockMethod(
|
||||
method_id="mobile_oper_spb_89",
|
||||
name="MobileOperSpb_89",
|
||||
description="Перебор мобильных номеров Санкт-Петербурга с префиксом 89.",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"hash_mode": 22000,
|
||||
"steps": [
|
||||
{
|
||||
"step_id": f"mask-{index:03d}",
|
||||
"attack_mode": 3,
|
||||
"mask": mask,
|
||||
}
|
||||
for index, mask in enumerate(
|
||||
(
|
||||
"891100?d?d?d?d?d",
|
||||
"891101?d?d?d?d?d",
|
||||
"891102?d?d?d?d?d",
|
||||
"891103?d?d?d?d?d",
|
||||
"891108?d?d?d?d?d",
|
||||
"891109?d?d?d?d?d",
|
||||
"891110?d?d?d?d?d",
|
||||
"891111?d?d?d?d?d",
|
||||
"891112?d?d?d?d?d",
|
||||
"891117?d?d?d?d?d",
|
||||
"891119?d?d?d?d?d",
|
||||
"89811?d?d?d?d?d?d",
|
||||
"89817?d?d?d?d?d?d",
|
||||
"89818?d?d?d?d?d?d",
|
||||
"89819?d?d?d?d?d?d",
|
||||
"898986?d?d?d?d?d",
|
||||
"898686?d?d?d?d?d",
|
||||
"898687?d?d?d?d?d",
|
||||
"898688?d?d?d?d?d",
|
||||
"892118?d?d?d?d?d",
|
||||
"892119?d?d?d?d?d",
|
||||
"89213?d?d?d?d?d?d",
|
||||
"89214?d?d?d?d?d?d",
|
||||
"89215?d?d?d?d?d?d",
|
||||
"89216?d?d?d?d?d?d",
|
||||
"89217?d?d?d?d?d?d",
|
||||
"89218?d?d?d?d?d?d",
|
||||
"89219?d?d?d?d?d?d",
|
||||
"893110?d?d?d?d?d",
|
||||
"893111?d?d?d?d?d",
|
||||
"893112?d?d?d?d?d",
|
||||
"893113?d?d?d?d?d",
|
||||
"89312?d?d?d?d?d?d",
|
||||
"89313?d?d?d?d?d?d",
|
||||
"89315?d?d?d?d?d?d",
|
||||
"89319?d?d?d?d?d?d",
|
||||
"89291?d?d?d?d?d?d",
|
||||
"899923?d?d?d?d?d",
|
||||
"899924?d?d?d?d?d",
|
||||
"899925?d?d?d?d?d",
|
||||
"8963241?d?d?d?d",
|
||||
"8963242?d?d?d?d",
|
||||
"8963243?d?d?d?d",
|
||||
"8963244?d?d?d?d",
|
||||
"8963245?d?d?d?d",
|
||||
"8963246?d?d?d?d",
|
||||
"8963247?d?d?d?d",
|
||||
"8963248?d?d?d?d",
|
||||
"8963249?d?d?d?d",
|
||||
"896330?d?d?d?d?d",
|
||||
"896331?d?d?d?d?d",
|
||||
"896332?d?d?d?d?d",
|
||||
"896334?d?d?d?d?d",
|
||||
"89650?d?d?d?d?d?d",
|
||||
"89651?d?d?d?d?d?d",
|
||||
"89652?d?d?d?d?d?d",
|
||||
"89653?d?d?d?d?d?d",
|
||||
"89654?d?d?d?d?d?d",
|
||||
"89655?d?d?d?d?d?d",
|
||||
"89656?d?d?d?d?d?d",
|
||||
"89657?d?d?d?d?d?d",
|
||||
"89658?d?d?d?d?d?d",
|
||||
"89659?d?d?d?d?d?d",
|
||||
"890520?d?d?d?d?d",
|
||||
"890521?d?d?d?d?d",
|
||||
"890522?d?d?d?d?d",
|
||||
"890523?d?d?d?d?d",
|
||||
"890524?d?d?d?d?d",
|
||||
"890525?d?d?d?d?d",
|
||||
"890526?d?d?d?d?d",
|
||||
"890527?d?d?d?d?d",
|
||||
"890528?d?d?d?d?d",
|
||||
"890624?d?d?d?d?d",
|
||||
"890625?d?d?d?d?d",
|
||||
"890626?d?d?d?d?d",
|
||||
"890627?d?d?d?d?d",
|
||||
"890958?d?d?d?d?d",
|
||||
"890959?d?d?d?d?d",
|
||||
"896023?d?d?d?d?d",
|
||||
"896024?d?d?d?d?d",
|
||||
"896025?d?d?d?d?d",
|
||||
"896026?d?d?d?d?d",
|
||||
"896027?d?d?d?d?d",
|
||||
"896028?d?d?d?d?d",
|
||||
"896434?d?d?d?d?d",
|
||||
"89500?d?d?d?d?d?d",
|
||||
"89502?d?d?d?d?d?d",
|
||||
"89516?d?d?d?d?d?d",
|
||||
"89517?d?d?d?d?d?d",
|
||||
"89518?d?d?d?d?d?d",
|
||||
"89522?d?d?d?d?d?d",
|
||||
"89523?d?d?d?d?d?d",
|
||||
"89531?d?d?d?d?d?d",
|
||||
"89533?d?d?d?d?d?d",
|
||||
"890433?d?d?d?d?d",
|
||||
"890451?d?d?d?d?d",
|
||||
"890455?d?d?d?d?d",
|
||||
"890460?d?d?d?d?d",
|
||||
"890461?d?d?d?d?d",
|
||||
"890462?d?d?d?d?d",
|
||||
"890463?d?d?d?d?d",
|
||||
"890464?d?d?d?d?d",
|
||||
"899100?d?d?d?d?d",
|
||||
"899101?d?d?d?d?d",
|
||||
"899102?d?d?d?d?d",
|
||||
"890062?d?d?d?d?d",
|
||||
"890063?d?d?d?d?d",
|
||||
"890064?d?d?d?d?d",
|
||||
"890065?d?d?d?d?d",
|
||||
"899900?d?d?d?d?d",
|
||||
"899901?d?d?d?d?d",
|
||||
"899902?d?d?d?d?d",
|
||||
"899903?d?d?d?d?d",
|
||||
"899904?d?d?d?d?d",
|
||||
"899909?d?d?d?d?d",
|
||||
"89991?d?d?d?d?d?d",
|
||||
"89992?d?d?d?d?d?d",
|
||||
"899322?d?d?d?d?d",
|
||||
"899343?d?d?d?d?d",
|
||||
"899344?d?d?d?d?d",
|
||||
"899555?d?d?d?d?d",
|
||||
"899559?d?d?d?d?d",
|
||||
"899560?d?d?d?d?d",
|
||||
"899590?d?d?d?d?d",
|
||||
"895840?d?d?d?d?d",
|
||||
"895856?d?d?d?d?d",
|
||||
"899511?d?d?d?d?d",
|
||||
"899512?d?d?d?d?d",
|
||||
"8999944?d?d?d?d",
|
||||
"8999945?d?d?d?d",
|
||||
"8999946?d?d?d?d",
|
||||
"8999947?d?d?d?d",
|
||||
),
|
||||
start=1,
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
StockMethod(
|
||||
method_id="mobile_oper_spb_79",
|
||||
name="MobileOperSpb_79",
|
||||
description="Перебор мобильных номеров Санкт-Петербурга без знака +.",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"hash_mode": 22000,
|
||||
"steps": [
|
||||
{
|
||||
"step_id": f"mask-{index:03d}",
|
||||
"attack_mode": 3,
|
||||
"mask": mask,
|
||||
}
|
||||
for index, mask in enumerate(
|
||||
(
|
||||
"791100?d?d?d?d?d",
|
||||
"791101?d?d?d?d?d",
|
||||
"791102?d?d?d?d?d",
|
||||
"791103?d?d?d?d?d",
|
||||
"791108?d?d?d?d?d",
|
||||
"791109?d?d?d?d?d",
|
||||
"791110?d?d?d?d?d",
|
||||
"791111?d?d?d?d?d",
|
||||
"791112?d?d?d?d?d",
|
||||
"791117?d?d?d?d?d",
|
||||
"791119?d?d?d?d?d",
|
||||
"79811?d?d?d?d?d?d",
|
||||
"79817?d?d?d?d?d?d",
|
||||
"79818?d?d?d?d?d?d",
|
||||
"79819?d?d?d?d?d?d",
|
||||
"798986?d?d?d?d?d",
|
||||
"798686?d?d?d?d?d",
|
||||
"798687?d?d?d?d?d",
|
||||
"798688?d?d?d?d?d",
|
||||
"792118?d?d?d?d?d",
|
||||
"792119?d?d?d?d?d",
|
||||
"79213?d?d?d?d?d?d",
|
||||
"79214?d?d?d?d?d?d",
|
||||
"79215?d?d?d?d?d?d",
|
||||
"79216?d?d?d?d?d?d",
|
||||
"79217?d?d?d?d?d?d",
|
||||
"79218?d?d?d?d?d?d",
|
||||
"79219?d?d?d?d?d?d",
|
||||
"793110?d?d?d?d?d",
|
||||
"793111?d?d?d?d?d",
|
||||
"793112?d?d?d?d?d",
|
||||
"793113?d?d?d?d?d",
|
||||
"79312?d?d?d?d?d?d",
|
||||
"79313?d?d?d?d?d?d",
|
||||
"79315?d?d?d?d?d?d",
|
||||
"79319?d?d?d?d?d?d",
|
||||
"79291?d?d?d?d?d?d",
|
||||
"799923?d?d?d?d?d",
|
||||
"799924?d?d?d?d?d",
|
||||
"799925?d?d?d?d?d",
|
||||
"7963241?d?d?d?d",
|
||||
"7963242?d?d?d?d",
|
||||
"7963243?d?d?d?d",
|
||||
"7963244?d?d?d?d",
|
||||
"7963245?d?d?d?d",
|
||||
"7963246?d?d?d?d",
|
||||
"7963247?d?d?d?d",
|
||||
"7963248?d?d?d?d",
|
||||
"7963249?d?d?d?d",
|
||||
"796330?d?d?d?d?d",
|
||||
"796331?d?d?d?d?d",
|
||||
"796332?d?d?d?d?d",
|
||||
"796334?d?d?d?d?d",
|
||||
"79650?d?d?d?d?d?d",
|
||||
"79651?d?d?d?d?d?d",
|
||||
"79652?d?d?d?d?d?d",
|
||||
"79653?d?d?d?d?d?d",
|
||||
"79654?d?d?d?d?d?d",
|
||||
"79655?d?d?d?d?d?d",
|
||||
"79656?d?d?d?d?d?d",
|
||||
"79657?d?d?d?d?d?d",
|
||||
"79658?d?d?d?d?d?d",
|
||||
"79659?d?d?d?d?d?d",
|
||||
"790520?d?d?d?d?d",
|
||||
"790521?d?d?d?d?d",
|
||||
"790522?d?d?d?d?d",
|
||||
"790523?d?d?d?d?d",
|
||||
"790524?d?d?d?d?d",
|
||||
"790525?d?d?d?d?d",
|
||||
"790526?d?d?d?d?d",
|
||||
"790527?d?d?d?d?d",
|
||||
"790528?d?d?d?d?d",
|
||||
"790624?d?d?d?d?d",
|
||||
"790625?d?d?d?d?d",
|
||||
"790626?d?d?d?d?d",
|
||||
"790627?d?d?d?d?d",
|
||||
"790958?d?d?d?d?d",
|
||||
"790959?d?d?d?d?d",
|
||||
"796023?d?d?d?d?d",
|
||||
"796024?d?d?d?d?d",
|
||||
"796025?d?d?d?d?d",
|
||||
"796026?d?d?d?d?d",
|
||||
"796027?d?d?d?d?d",
|
||||
"796028?d?d?d?d?d",
|
||||
"796434?d?d?d?d?d",
|
||||
"79500?d?d?d?d?d?d",
|
||||
"79502?d?d?d?d?d?d",
|
||||
"79516?d?d?d?d?d?d",
|
||||
"79517?d?d?d?d?d?d",
|
||||
"79518?d?d?d?d?d?d",
|
||||
"79522?d?d?d?d?d?d",
|
||||
"79523?d?d?d?d?d?d",
|
||||
"79531?d?d?d?d?d?d",
|
||||
"79533?d?d?d?d?d?d",
|
||||
"790433?d?d?d?d?d",
|
||||
"790451?d?d?d?d?d",
|
||||
"790455?d?d?d?d?d",
|
||||
"790460?d?d?d?d?d",
|
||||
"790461?d?d?d?d?d",
|
||||
"790462?d?d?d?d?d",
|
||||
"790463?d?d?d?d?d",
|
||||
"790464?d?d?d?d?d",
|
||||
"799100?d?d?d?d?d",
|
||||
"799101?d?d?d?d?d",
|
||||
"799102?d?d?d?d?d",
|
||||
"790062?d?d?d?d?d",
|
||||
"790063?d?d?d?d?d",
|
||||
"790064?d?d?d?d?d",
|
||||
"790065?d?d?d?d?d",
|
||||
"799900?d?d?d?d?d",
|
||||
"799901?d?d?d?d?d",
|
||||
"799902?d?d?d?d?d",
|
||||
"799903?d?d?d?d?d",
|
||||
"799904?d?d?d?d?d",
|
||||
"799909?d?d?d?d?d",
|
||||
"79991?d?d?d?d?d?d",
|
||||
"79992?d?d?d?d?d?d",
|
||||
"799322?d?d?d?d?d",
|
||||
"799343?d?d?d?d?d",
|
||||
"799344?d?d?d?d?d",
|
||||
"799555?d?d?d?d?d",
|
||||
"799559?d?d?d?d?d",
|
||||
"799560?d?d?d?d?d",
|
||||
"799590?d?d?d?d?d",
|
||||
"795840?d?d?d?d?d",
|
||||
"795856?d?d?d?d?d",
|
||||
"799511?d?d?d?d?d",
|
||||
"799512?d?d?d?d?d",
|
||||
"7999944?d?d?d?d",
|
||||
"7999945?d?d?d?d",
|
||||
"7999946?d?d?d?d",
|
||||
"7999947?d?d?d?d",
|
||||
),
|
||||
start=1,
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
StockMethod(
|
||||
method_id="mobile_oper_spb_9",
|
||||
name="MobileOperSpb_9",
|
||||
description="Перебор мобильных номеров Санкт-Петербурга без 7 и знака +.",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"hash_mode": 22000,
|
||||
"steps": [
|
||||
{
|
||||
"step_id": f"mask-{index:03d}",
|
||||
"attack_mode": 3,
|
||||
"mask": mask,
|
||||
}
|
||||
for index, mask in enumerate(
|
||||
(
|
||||
"91100?d?d?d?d?d",
|
||||
"91101?d?d?d?d?d",
|
||||
"91102?d?d?d?d?d",
|
||||
"91103?d?d?d?d?d",
|
||||
"91108?d?d?d?d?d",
|
||||
"91109?d?d?d?d?d",
|
||||
"91110?d?d?d?d?d",
|
||||
"91111?d?d?d?d?d",
|
||||
"91112?d?d?d?d?d",
|
||||
"91117?d?d?d?d?d",
|
||||
"91119?d?d?d?d?d",
|
||||
"9811?d?d?d?d?d?d",
|
||||
"9817?d?d?d?d?d?d",
|
||||
"9818?d?d?d?d?d?d",
|
||||
"9819?d?d?d?d?d?d",
|
||||
"98986?d?d?d?d?d",
|
||||
"98686?d?d?d?d?d",
|
||||
"98687?d?d?d?d?d",
|
||||
"98688?d?d?d?d?d",
|
||||
"92118?d?d?d?d?d",
|
||||
"92119?d?d?d?d?d",
|
||||
"9213?d?d?d?d?d?d",
|
||||
"9214?d?d?d?d?d?d",
|
||||
"9215?d?d?d?d?d?d",
|
||||
"9216?d?d?d?d?d?d",
|
||||
"9217?d?d?d?d?d?d",
|
||||
"9218?d?d?d?d?d?d",
|
||||
"9219?d?d?d?d?d?d",
|
||||
"93110?d?d?d?d?d",
|
||||
"93111?d?d?d?d?d",
|
||||
"93112?d?d?d?d?d",
|
||||
"93113?d?d?d?d?d",
|
||||
"9312?d?d?d?d?d?d",
|
||||
"9313?d?d?d?d?d?d",
|
||||
"9315?d?d?d?d?d?d",
|
||||
"9319?d?d?d?d?d?d",
|
||||
"9291?d?d?d?d?d?d",
|
||||
"99923?d?d?d?d?d",
|
||||
"99924?d?d?d?d?d",
|
||||
"99925?d?d?d?d?d",
|
||||
"963241?d?d?d?d",
|
||||
"963242?d?d?d?d",
|
||||
"963243?d?d?d?d",
|
||||
"963244?d?d?d?d",
|
||||
"963245?d?d?d?d",
|
||||
"963246?d?d?d?d",
|
||||
"963247?d?d?d?d",
|
||||
"963248?d?d?d?d",
|
||||
"963249?d?d?d?d",
|
||||
"96330?d?d?d?d?d",
|
||||
"96331?d?d?d?d?d",
|
||||
"96332?d?d?d?d?d",
|
||||
"96334?d?d?d?d?d",
|
||||
"9650?d?d?d?d?d?d",
|
||||
"9651?d?d?d?d?d?d",
|
||||
"9652?d?d?d?d?d?d",
|
||||
"9653?d?d?d?d?d?d",
|
||||
"9654?d?d?d?d?d?d",
|
||||
"9655?d?d?d?d?d?d",
|
||||
"9656?d?d?d?d?d?d",
|
||||
"9657?d?d?d?d?d?d",
|
||||
"9658?d?d?d?d?d?d",
|
||||
"9659?d?d?d?d?d?d",
|
||||
"90520?d?d?d?d?d",
|
||||
"90521?d?d?d?d?d",
|
||||
"90522?d?d?d?d?d",
|
||||
"90523?d?d?d?d?d",
|
||||
"90524?d?d?d?d?d",
|
||||
"90525?d?d?d?d?d",
|
||||
"90526?d?d?d?d?d",
|
||||
"90527?d?d?d?d?d",
|
||||
"90528?d?d?d?d?d",
|
||||
"90624?d?d?d?d?d",
|
||||
"90625?d?d?d?d?d",
|
||||
"90626?d?d?d?d?d",
|
||||
"90627?d?d?d?d?d",
|
||||
"90958?d?d?d?d?d",
|
||||
"90959?d?d?d?d?d",
|
||||
"96023?d?d?d?d?d",
|
||||
"96024?d?d?d?d?d",
|
||||
"96025?d?d?d?d?d",
|
||||
"96026?d?d?d?d?d",
|
||||
"96027?d?d?d?d?d",
|
||||
"96028?d?d?d?d?d",
|
||||
"96434?d?d?d?d?d",
|
||||
"9500?d?d?d?d?d?d",
|
||||
"9502?d?d?d?d?d?d",
|
||||
"9516?d?d?d?d?d?d",
|
||||
"9517?d?d?d?d?d?d",
|
||||
"9518?d?d?d?d?d?d",
|
||||
"9522?d?d?d?d?d?d",
|
||||
"9523?d?d?d?d?d?d",
|
||||
"9531?d?d?d?d?d?d",
|
||||
"9533?d?d?d?d?d?d",
|
||||
"90433?d?d?d?d?d",
|
||||
"90451?d?d?d?d?d",
|
||||
"90455?d?d?d?d?d",
|
||||
"90460?d?d?d?d?d",
|
||||
"90461?d?d?d?d?d",
|
||||
"90462?d?d?d?d?d",
|
||||
"90463?d?d?d?d?d",
|
||||
"90464?d?d?d?d?d",
|
||||
"99100?d?d?d?d?d",
|
||||
"99101?d?d?d?d?d",
|
||||
"99102?d?d?d?d?d",
|
||||
"90062?d?d?d?d?d",
|
||||
"90063?d?d?d?d?d",
|
||||
"90064?d?d?d?d?d",
|
||||
"90065?d?d?d?d?d",
|
||||
"99900?d?d?d?d?d",
|
||||
"99901?d?d?d?d?d",
|
||||
"99902?d?d?d?d?d",
|
||||
"99903?d?d?d?d?d",
|
||||
"99904?d?d?d?d?d",
|
||||
"99909?d?d?d?d?d",
|
||||
"9991?d?d?d?d?d?d",
|
||||
"9992?d?d?d?d?d?d",
|
||||
"99322?d?d?d?d?d",
|
||||
"99343?d?d?d?d?d",
|
||||
"99344?d?d?d?d?d",
|
||||
"99555?d?d?d?d?d",
|
||||
"99559?d?d?d?d?d",
|
||||
"99560?d?d?d?d?d",
|
||||
"99590?d?d?d?d?d",
|
||||
"95840?d?d?d?d?d",
|
||||
"95856?d?d?d?d?d",
|
||||
"99511?d?d?d?d?d",
|
||||
"99512?d?d?d?d?d",
|
||||
"999944?d?d?d?d",
|
||||
"999945?d?d?d?d",
|
||||
"999946?d?d?d?d",
|
||||
"999947?d?d?d?d",
|
||||
),
|
||||
start=1,
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
StockMethod(
|
||||
method_id="mobile_oper_plus_79",
|
||||
name="MobileOper+79",
|
||||
description="Перебор мобильных номеров с префиксом +79.",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"hash_mode": 22000,
|
||||
"steps": [
|
||||
{
|
||||
"step_id": f"mask-{index:03d}",
|
||||
"attack_mode": 3,
|
||||
"mask": mask,
|
||||
}
|
||||
for index, mask in enumerate(
|
||||
(
|
||||
"+7911?d?d?d?d?d?d?d",
|
||||
"+7904?d?d?d?d?d?d?d",
|
||||
"+7931?d?d?d?d?d?d?d",
|
||||
"+7921?d?d?d?d?d?d?d",
|
||||
"+7999?d?d?d?d?d?d?d",
|
||||
"+7905?d?d?d?d?d?d?d",
|
||||
"+7951?d?d?d?d?d?d?d",
|
||||
"+7952?d?d?d?d?d?d?d",
|
||||
"+7953?d?d?d?d?d?d?d",
|
||||
"+7901?d?d?d?d?d?d?d",
|
||||
"+7903?d?d?d?d?d?d?d",
|
||||
"+7950?d?d?d?d?d?d?d",
|
||||
"+7906?d?d?d?d?d?d?d",
|
||||
"+7909?d?d?d?d?d?d?d",
|
||||
"+7929?d?d?d?d?d?d?d",
|
||||
"+7960?d?d?d?d?d?d?d",
|
||||
"+7961?d?d?d?d?d?d?d",
|
||||
"+7962?d?d?d?d?d?d?d",
|
||||
"+7963?d?d?d?d?d?d?d",
|
||||
"+7965?d?d?d?d?d?d?d",
|
||||
"+7966?d?d?d?d?d?d?d",
|
||||
"+7981?d?d?d?d?d?d?d",
|
||||
),
|
||||
start=1,
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
StockMethod(
|
||||
method_id="mobile_oper_89",
|
||||
name="MobileOper_89",
|
||||
description="Перебор мобильных номеров с префиксом 89.",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"hash_mode": 22000,
|
||||
"steps": [
|
||||
{
|
||||
"step_id": f"mask-{index:03d}",
|
||||
"attack_mode": 3,
|
||||
"mask": mask,
|
||||
}
|
||||
for index, mask in enumerate(
|
||||
(
|
||||
"8911?d?d?d?d?d?d?d",
|
||||
"8904?d?d?d?d?d?d?d",
|
||||
"8931?d?d?d?d?d?d?d",
|
||||
"8921?d?d?d?d?d?d?d",
|
||||
"8999?d?d?d?d?d?d?d",
|
||||
"8905?d?d?d?d?d?d?d",
|
||||
"8951?d?d?d?d?d?d?d",
|
||||
"8952?d?d?d?d?d?d?d",
|
||||
"8953?d?d?d?d?d?d?d",
|
||||
"8901?d?d?d?d?d?d?d",
|
||||
"8903?d?d?d?d?d?d?d",
|
||||
"8950?d?d?d?d?d?d?d",
|
||||
"8906?d?d?d?d?d?d?d",
|
||||
"8909?d?d?d?d?d?d?d",
|
||||
"8929?d?d?d?d?d?d?d",
|
||||
"8960?d?d?d?d?d?d?d",
|
||||
"8961?d?d?d?d?d?d?d",
|
||||
"8962?d?d?d?d?d?d?d",
|
||||
"8963?d?d?d?d?d?d?d",
|
||||
"8965?d?d?d?d?d?d?d",
|
||||
"8966?d?d?d?d?d?d?d",
|
||||
"8981?d?d?d?d?d?d?d",
|
||||
),
|
||||
start=1,
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
StockMethod(
|
||||
method_id="mobile_oper_79",
|
||||
name="MobileOper_79",
|
||||
description="Перебор мобильных номеров без знака +.",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"hash_mode": 22000,
|
||||
"steps": [
|
||||
{
|
||||
"step_id": f"mask-{index:03d}",
|
||||
"attack_mode": 3,
|
||||
"mask": mask,
|
||||
}
|
||||
for index, mask in enumerate(
|
||||
(
|
||||
"7911?d?d?d?d?d?d?d",
|
||||
"7904?d?d?d?d?d?d?d",
|
||||
"7931?d?d?d?d?d?d?d",
|
||||
"7921?d?d?d?d?d?d?d",
|
||||
"7999?d?d?d?d?d?d?d",
|
||||
"7905?d?d?d?d?d?d?d",
|
||||
"7951?d?d?d?d?d?d?d",
|
||||
"7952?d?d?d?d?d?d?d",
|
||||
"7953?d?d?d?d?d?d?d",
|
||||
"7901?d?d?d?d?d?d?d",
|
||||
"7903?d?d?d?d?d?d?d",
|
||||
"7950?d?d?d?d?d?d?d",
|
||||
"7906?d?d?d?d?d?d?d",
|
||||
"7909?d?d?d?d?d?d?d",
|
||||
"7929?d?d?d?d?d?d?d",
|
||||
"7960?d?d?d?d?d?d?d",
|
||||
"7961?d?d?d?d?d?d?d",
|
||||
"7962?d?d?d?d?d?d?d",
|
||||
"7963?d?d?d?d?d?d?d",
|
||||
"7965?d?d?d?d?d?d?d",
|
||||
"7966?d?d?d?d?d?d?d",
|
||||
"7981?d?d?d?d?d?d?d",
|
||||
),
|
||||
start=1,
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
StockMethod(
|
||||
method_id="mobile_oper_9",
|
||||
name="MobileOper_9",
|
||||
description="Перебор мобильных номеров без 7 и знака +.",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"hash_mode": 22000,
|
||||
"steps": [
|
||||
{
|
||||
"step_id": f"mask-{index:03d}",
|
||||
"attack_mode": 3,
|
||||
"mask": mask,
|
||||
}
|
||||
for index, mask in enumerate(
|
||||
(
|
||||
"911?d?d?d?d?d?d?d",
|
||||
"904?d?d?d?d?d?d?d",
|
||||
"931?d?d?d?d?d?d?d",
|
||||
"921?d?d?d?d?d?d?d",
|
||||
"999?d?d?d?d?d?d?d",
|
||||
"905?d?d?d?d?d?d?d",
|
||||
"951?d?d?d?d?d?d?d",
|
||||
"952?d?d?d?d?d?d?d",
|
||||
"953?d?d?d?d?d?d?d",
|
||||
"901?d?d?d?d?d?d?d",
|
||||
"903?d?d?d?d?d?d?d",
|
||||
"950?d?d?d?d?d?d?d",
|
||||
"906?d?d?d?d?d?d?d",
|
||||
"909?d?d?d?d?d?d?d",
|
||||
"929?d?d?d?d?d?d?d",
|
||||
"960?d?d?d?d?d?d?d",
|
||||
"961?d?d?d?d?d?d?d",
|
||||
"962?d?d?d?d?d?d?d",
|
||||
"963?d?d?d?d?d?d?d",
|
||||
"965?d?d?d?d?d?d?d",
|
||||
"966?d?d?d?d?d?d?d",
|
||||
"981?d?d?d?d?d?d?d",
|
||||
),
|
||||
start=1,
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
StockMethod(
|
||||
method_id="mask_8_digits",
|
||||
name="8-digital",
|
||||
description="Перебор восьмизначных числовых паролей.",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"hash_mode": 22000,
|
||||
"steps": [
|
||||
{
|
||||
"step_id": "mask-001",
|
||||
"attack_mode": 3,
|
||||
"mask": "?d?d?d?d?d?d?d?d",
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
StockMethod(
|
||||
method_id="breachcompilation",
|
||||
name="BreachCompilation",
|
||||
description="Перебор по словарю BreachCompilation.",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"hash_mode": 22000,
|
||||
"steps": [
|
||||
{
|
||||
"step_id": "dictionary-001",
|
||||
"attack_mode": 0,
|
||||
"dictionary": {
|
||||
"resource": "wordlist:breachcompilation",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
StockMethod(
|
||||
method_id="insidepro_wpa",
|
||||
name="insidePRO_WPA",
|
||||
description="Перебор по словарю insidePRO_WPA.",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"hash_mode": 22000,
|
||||
"steps": [
|
||||
{
|
||||
"step_id": "dictionary-001",
|
||||
"attack_mode": 0,
|
||||
"dictionary": {
|
||||
"resource": "wordlist:insidepro-wpa",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class StockMethodSeeder:
|
||||
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||
self._conn = conn
|
||||
self._methods = MethodService(conn)
|
||||
|
||||
def seed(self) -> None:
|
||||
with self._conn:
|
||||
for stock_method in STOCK_METHODS:
|
||||
if stock_method.definition is None:
|
||||
continue
|
||||
|
||||
existing_method = self._methods.get_method(
|
||||
stock_method.method_id
|
||||
)
|
||||
|
||||
if existing_method is None:
|
||||
self._methods.create_method(
|
||||
method_id=stock_method.method_id,
|
||||
name=stock_method.name,
|
||||
description=stock_method.description,
|
||||
)
|
||||
else:
|
||||
if existing_method["name"] != stock_method.name:
|
||||
raise ValueError(
|
||||
"Stock method ID is already used with a "
|
||||
f"different name: {stock_method.method_id}"
|
||||
)
|
||||
|
||||
existing_version = self._methods.get_method_version(
|
||||
stock_method.method_id,
|
||||
1,
|
||||
)
|
||||
|
||||
if existing_version is None:
|
||||
self._methods.create_method_version(
|
||||
method_id=stock_method.method_id,
|
||||
version=1,
|
||||
definition=stock_method.definition,
|
||||
)
|
||||
elif existing_version["definition_json"] != (
|
||||
self._canonical_json(stock_method.definition)
|
||||
):
|
||||
raise ValueError(
|
||||
"Stock method version 1 already exists with "
|
||||
f"different definition: {stock_method.method_id}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _canonical_json(definition: dict[str, Any]) -> str:
|
||||
import json
|
||||
|
||||
return json.dumps(
|
||||
definition,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
from flask import Blueprint
|
||||
|
||||
|
||||
cracklab_bp = Blueprint(
|
||||
"cracklab",
|
||||
__name__,
|
||||
url_prefix="/cracklab",
|
||||
template_folder="templates",
|
||||
static_folder="static",
|
||||
static_url_path="/static",
|
||||
)
|
||||
|
||||
|
||||
from cracklab.server.web import routes # noqa: E402,F401
|
||||
@@ -0,0 +1,408 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import Counter
|
||||
|
||||
from flask import redirect, render_template, request, url_for
|
||||
|
||||
from cracklab.server.db.connection import connect_database
|
||||
from cracklab.server.repositories.clients import ClientsRepository
|
||||
from cracklab.server.repositories.jobs import JobsRepository
|
||||
from cracklab.server.repositories.methods import MethodsRepository
|
||||
from cracklab.server.repositories.results import ResultsRepository
|
||||
from cracklab.server.services.methods import MethodService, MethodServiceError
|
||||
|
||||
from cracklab.server.web import cracklab_bp
|
||||
|
||||
|
||||
@cracklab_bp.route("/")
|
||||
def dashboard():
|
||||
conn = connect_database()
|
||||
|
||||
try:
|
||||
methods = MethodsRepository(conn).list_methods()
|
||||
jobs = JobsRepository(conn).list_jobs()
|
||||
clients = ClientsRepository(conn).list_clients()
|
||||
results = ResultsRepository(conn).list_results()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
job_statuses = Counter(
|
||||
row["status"]
|
||||
for row in jobs
|
||||
)
|
||||
|
||||
recent_jobs = jobs[:10]
|
||||
|
||||
method_names = {
|
||||
row["id"]: row["name"]
|
||||
for row in methods
|
||||
}
|
||||
|
||||
client_names = {
|
||||
row["id"]: row["name"]
|
||||
for row in clients
|
||||
}
|
||||
|
||||
return render_template(
|
||||
"dashboard.html",
|
||||
active_page="dashboard",
|
||||
methods_count=len(methods),
|
||||
jobs_count=len(jobs),
|
||||
clients_count=len(clients),
|
||||
results_count=len(results),
|
||||
active_jobs_count=sum(
|
||||
job_statuses.get(status, 0)
|
||||
for status in (
|
||||
"CREATED",
|
||||
"QUEUED",
|
||||
"RUNNING",
|
||||
"PARTIAL",
|
||||
)
|
||||
),
|
||||
job_statuses=job_statuses,
|
||||
recent_jobs=recent_jobs,
|
||||
method_names=method_names,
|
||||
client_names=client_names,
|
||||
)
|
||||
|
||||
|
||||
@cracklab_bp.route("/jobs/")
|
||||
def jobs_page():
|
||||
return _placeholder(
|
||||
"Jobs",
|
||||
"Job management will be implemented here.",
|
||||
"jobs",
|
||||
)
|
||||
|
||||
|
||||
@cracklab_bp.route("/methods/")
|
||||
def methods_page():
|
||||
conn = connect_database()
|
||||
|
||||
try:
|
||||
service = MethodService(conn)
|
||||
methods = service.list_methods()
|
||||
|
||||
method_rows = []
|
||||
|
||||
for method in methods:
|
||||
versions = service.list_method_versions(method["id"])
|
||||
|
||||
latest = versions[-1] if versions else None
|
||||
|
||||
steps_count = 0
|
||||
|
||||
if latest is not None:
|
||||
try:
|
||||
definition = json.loads(
|
||||
latest["definition_json"]
|
||||
)
|
||||
steps_count = len(
|
||||
definition.get("steps", [])
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
steps_count = 0
|
||||
|
||||
method_rows.append(
|
||||
{
|
||||
"id": method["id"],
|
||||
"name": method["name"],
|
||||
"description": method["description"],
|
||||
"created_at": method["created_at"],
|
||||
"version": (
|
||||
latest["version"]
|
||||
if latest is not None
|
||||
else None
|
||||
),
|
||||
"steps_count": steps_count,
|
||||
}
|
||||
)
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return render_template(
|
||||
"methods.html",
|
||||
active_page="methods",
|
||||
methods=method_rows,
|
||||
)
|
||||
|
||||
|
||||
@cracklab_bp.route("/methods/create/", methods=["GET", "POST"])
|
||||
def method_create_page():
|
||||
error = None
|
||||
|
||||
if request.method == "POST":
|
||||
try:
|
||||
name = request.form.get("name", "").strip()
|
||||
description = request.form.get(
|
||||
"description",
|
||||
"",
|
||||
).strip()
|
||||
|
||||
hash_mode_raw = request.form.get(
|
||||
"hash_mode",
|
||||
"22000",
|
||||
).strip()
|
||||
|
||||
if not name:
|
||||
raise MethodServiceError(
|
||||
"Введите название метода."
|
||||
)
|
||||
|
||||
if hash_mode_raw != "22000":
|
||||
raise MethodServiceError(
|
||||
"Сейчас поддерживается только hash mode 22000."
|
||||
)
|
||||
|
||||
hash_mode = 22000
|
||||
|
||||
steps = _build_steps_from_form()
|
||||
|
||||
definition = {
|
||||
"schema_version": 1,
|
||||
"hash_mode": hash_mode,
|
||||
"steps": steps,
|
||||
}
|
||||
|
||||
conn = connect_database()
|
||||
|
||||
try:
|
||||
service = MethodService(conn)
|
||||
|
||||
with conn:
|
||||
method_id = service.create_method(
|
||||
name=name,
|
||||
description=description or None,
|
||||
)
|
||||
|
||||
service.create_method_version(
|
||||
method_id=method_id,
|
||||
version=1,
|
||||
definition=definition,
|
||||
)
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return redirect(
|
||||
url_for(
|
||||
"cracklab.methods_page",
|
||||
)
|
||||
)
|
||||
|
||||
except (MethodServiceError, ValueError) as exc:
|
||||
error = str(exc)
|
||||
|
||||
return render_template(
|
||||
"method_create.html",
|
||||
active_page="methods",
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
def _build_steps_from_form() -> list[dict]:
|
||||
step_count_raw = request.form.get(
|
||||
"step_count",
|
||||
"1",
|
||||
).strip()
|
||||
|
||||
try:
|
||||
step_count = int(step_count_raw)
|
||||
except ValueError as exc:
|
||||
raise MethodServiceError(
|
||||
"Некорректное количество шагов."
|
||||
) from exc
|
||||
|
||||
if step_count < 1:
|
||||
raise MethodServiceError(
|
||||
"Метод должен содержать хотя бы один шаг."
|
||||
)
|
||||
|
||||
steps = []
|
||||
|
||||
for index in range(1, step_count + 1):
|
||||
prefix = f"step_{index}"
|
||||
|
||||
attack_mode_raw = request.form.get(
|
||||
f"{prefix}_attack_mode",
|
||||
"",
|
||||
).strip()
|
||||
|
||||
if not attack_mode_raw:
|
||||
raise MethodServiceError(
|
||||
f"Для шага {index} не выбран attack mode."
|
||||
)
|
||||
|
||||
try:
|
||||
attack_mode = int(attack_mode_raw)
|
||||
except ValueError as exc:
|
||||
raise MethodServiceError(
|
||||
f"Некорректный attack mode в шаге {index}."
|
||||
) from exc
|
||||
|
||||
if attack_mode not in (0, 1, 3, 6, 7):
|
||||
raise MethodServiceError(
|
||||
f"Attack mode {attack_mode} "
|
||||
f"не поддерживается клиентом CrackLab."
|
||||
)
|
||||
|
||||
step = {
|
||||
"step_id": f"step-{index:03d}",
|
||||
"attack_mode": attack_mode,
|
||||
}
|
||||
|
||||
workload_raw = request.form.get(
|
||||
f"{prefix}_workload_profile",
|
||||
"2",
|
||||
).strip()
|
||||
|
||||
if workload_raw:
|
||||
try:
|
||||
workload_profile = int(workload_raw)
|
||||
except ValueError as exc:
|
||||
raise MethodServiceError(
|
||||
f"Некорректный workload profile "
|
||||
f"в шаге {index}."
|
||||
) from exc
|
||||
|
||||
if workload_profile not in (1, 2, 3, 4):
|
||||
raise MethodServiceError(
|
||||
f"Workload profile в шаге {index} "
|
||||
"должен быть от 1 до 4."
|
||||
)
|
||||
|
||||
step["workload_profile"] = workload_profile
|
||||
|
||||
if attack_mode == 0:
|
||||
step["dictionary"] = {
|
||||
"resource": _get_dictionary(
|
||||
prefix,
|
||||
"dictionary",
|
||||
index,
|
||||
),
|
||||
}
|
||||
|
||||
elif attack_mode == 1:
|
||||
step["dictionaries"] = [
|
||||
{
|
||||
"resource": _get_dictionary(
|
||||
prefix,
|
||||
"dictionary_1",
|
||||
index,
|
||||
),
|
||||
},
|
||||
{
|
||||
"resource": _get_dictionary(
|
||||
prefix,
|
||||
"dictionary_2",
|
||||
index,
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
elif attack_mode == 3:
|
||||
step["mask"] = _get_mask(
|
||||
prefix,
|
||||
index,
|
||||
)
|
||||
|
||||
elif attack_mode == 6:
|
||||
step["dictionary"] = {
|
||||
"resource": _get_dictionary(
|
||||
prefix,
|
||||
"dictionary",
|
||||
index,
|
||||
),
|
||||
}
|
||||
step["mask"] = _get_mask(
|
||||
prefix,
|
||||
index,
|
||||
)
|
||||
|
||||
elif attack_mode == 7:
|
||||
step["mask"] = _get_mask(
|
||||
prefix,
|
||||
index,
|
||||
)
|
||||
step["dictionary"] = {
|
||||
"resource": _get_dictionary(
|
||||
prefix,
|
||||
"dictionary",
|
||||
index,
|
||||
),
|
||||
}
|
||||
|
||||
steps.append(step)
|
||||
|
||||
return steps
|
||||
|
||||
|
||||
def _get_dictionary(
|
||||
prefix: str,
|
||||
field: str,
|
||||
index: int,
|
||||
) -> str:
|
||||
value = request.form.get(
|
||||
f"{prefix}_{field}",
|
||||
"",
|
||||
).strip()
|
||||
|
||||
if not value:
|
||||
raise MethodServiceError(
|
||||
f"Для шага {index} необходимо указать словарь."
|
||||
)
|
||||
|
||||
if not value.startswith("wordlist:"):
|
||||
value = f"wordlist:{value}"
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _get_mask(
|
||||
prefix: str,
|
||||
index: int,
|
||||
) -> str:
|
||||
value = request.form.get(
|
||||
f"{prefix}_mask",
|
||||
"",
|
||||
).strip()
|
||||
|
||||
if not value:
|
||||
raise MethodServiceError(
|
||||
f"Для шага {index} необходимо указать маску."
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
@cracklab_bp.route("/clients/")
|
||||
def clients_page():
|
||||
return _placeholder(
|
||||
"Clients",
|
||||
"Client management will be implemented here.",
|
||||
"clients",
|
||||
)
|
||||
|
||||
|
||||
@cracklab_bp.route("/results/")
|
||||
def results_page():
|
||||
return _placeholder(
|
||||
"Results",
|
||||
"Result history will be implemented here.",
|
||||
"results",
|
||||
)
|
||||
|
||||
|
||||
def _placeholder(
|
||||
title: str,
|
||||
description: str,
|
||||
active_page: str,
|
||||
):
|
||||
return render_template(
|
||||
"placeholder.html",
|
||||
active_page=active_page,
|
||||
title=title,
|
||||
description=description,
|
||||
)
|
||||
@@ -0,0 +1,785 @@
|
||||
@font-face {
|
||||
font-family: "Bender";
|
||||
|
||||
src:
|
||||
url("fonts/Bender.woff2") format("woff2"),
|
||||
url("fonts/Bender.woff") format("woff");
|
||||
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
|
||||
@font-face {
|
||||
font-family: "Bender";
|
||||
|
||||
src:
|
||||
url("fonts/Bender-Bold.woff2") format("woff2"),
|
||||
url("fonts/Bender-Bold.woff") format("woff");
|
||||
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
|
||||
@font-face {
|
||||
font-family: "Bender";
|
||||
|
||||
src:
|
||||
url("fonts/Bender-Light.woff2") format("woff2"),
|
||||
url("fonts/Bender-Light.woff") format("woff");
|
||||
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
|
||||
:root {
|
||||
--bg: #0f141a;
|
||||
--surface: #171e26;
|
||||
--surface-light: #1c252f;
|
||||
--border: #2b3743;
|
||||
|
||||
--text: #e6edf3;
|
||||
--muted: #8b98a6;
|
||||
|
||||
--green: #45d483;
|
||||
--yellow: #e5c07b;
|
||||
--red: #ef6262;
|
||||
--accent: #69a7ff;
|
||||
|
||||
--terminal: #0a0f14;
|
||||
}
|
||||
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
||||
html {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
min-height: 100vh;
|
||||
|
||||
font-family:
|
||||
"Bender",
|
||||
Arial,
|
||||
Helvetica,
|
||||
sans-serif;
|
||||
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
|
||||
a:hover {
|
||||
color: #9bc4ff;
|
||||
}
|
||||
|
||||
|
||||
.cracklab-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
||||
z-index: 1000;
|
||||
|
||||
width: 100%;
|
||||
|
||||
background: rgba(15, 20, 26, 0.96);
|
||||
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
|
||||
.cracklab-header-inner {
|
||||
width: 100%;
|
||||
|
||||
max-width: 1800px;
|
||||
|
||||
margin: 0 auto;
|
||||
|
||||
min-height: 68px;
|
||||
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
gap: 36px;
|
||||
|
||||
padding: 0 30px;
|
||||
}
|
||||
|
||||
|
||||
.cracklab-brand {
|
||||
flex: 0 0 auto;
|
||||
|
||||
display: flex;
|
||||
|
||||
flex-direction: column;
|
||||
|
||||
justify-content: center;
|
||||
|
||||
color: var(--text);
|
||||
|
||||
text-decoration: none;
|
||||
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
.cracklab-brand:hover {
|
||||
color: var(--text);
|
||||
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
|
||||
.cracklab-brand-title {
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
|
||||
letter-spacing: 0.4px;
|
||||
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
|
||||
.cracklab-brand-subtitle {
|
||||
margin-top: 3px;
|
||||
|
||||
color: var(--muted);
|
||||
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
|
||||
letter-spacing: 0.7px;
|
||||
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
|
||||
.cracklab-nav {
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
gap: 6px;
|
||||
|
||||
min-width: 0;
|
||||
|
||||
overflow-x: auto;
|
||||
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
|
||||
.cracklab-nav a {
|
||||
display: inline-flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
min-height: 38px;
|
||||
|
||||
padding: 0 14px;
|
||||
|
||||
border-radius: 7px;
|
||||
|
||||
color: var(--muted);
|
||||
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
|
||||
letter-spacing: 0.7px;
|
||||
|
||||
white-space: nowrap;
|
||||
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background 0.15s ease;
|
||||
}
|
||||
|
||||
|
||||
.cracklab-nav a:hover {
|
||||
color: var(--text);
|
||||
|
||||
background: var(--surface-light);
|
||||
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
|
||||
.cracklab-nav a.active {
|
||||
color: var(--text);
|
||||
|
||||
background: var(--surface-light);
|
||||
|
||||
box-shadow:
|
||||
inset 0 -2px 0 var(--accent);
|
||||
}
|
||||
|
||||
|
||||
.cracklab-content {
|
||||
width: 100%;
|
||||
|
||||
max-width: 1800px;
|
||||
|
||||
margin: 0 auto;
|
||||
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
|
||||
.page-title {
|
||||
margin-top: 0;
|
||||
margin-bottom: 8px;
|
||||
|
||||
font-size: 34px;
|
||||
font-weight: 700;
|
||||
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
|
||||
.page-description {
|
||||
color: var(--muted);
|
||||
|
||||
margin-bottom: 30px;
|
||||
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.methods-page {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.methods-page-header {
|
||||
display: flex;
|
||||
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
|
||||
gap: 24px;
|
||||
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
|
||||
.methods-page-header .page-description {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
.methods-button,
|
||||
.methods-button-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
min-height: 42px;
|
||||
padding: 10px 18px;
|
||||
|
||||
border: 2px outset ButtonFace;
|
||||
border-radius: 7px;
|
||||
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
}
|
||||
|
||||
.methods-button {
|
||||
background: var(--accent);
|
||||
color: #0f141a;
|
||||
}
|
||||
|
||||
.methods-button:hover {
|
||||
background: #8abaff;
|
||||
color: #0f141a;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.methods-button:active {
|
||||
border-style: inset;
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.methods-button-secondary {
|
||||
background: var(--surface-light);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.methods-button-secondary:hover {
|
||||
background: var(--border);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.methods-button-secondary:active {
|
||||
border-style: inset;
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
|
||||
.methods-list {
|
||||
display: grid;
|
||||
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
|
||||
.method-card {
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
gap: 30px;
|
||||
|
||||
padding: 20px 22px;
|
||||
|
||||
background: var(--surface);
|
||||
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
|
||||
box-shadow:
|
||||
0 2px 10px rgba(0, 0, 0, 0.20);
|
||||
}
|
||||
|
||||
|
||||
.method-card-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
|
||||
.method-card-title {
|
||||
margin-bottom: 7px;
|
||||
|
||||
color: var(--text);
|
||||
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
|
||||
.method-card-description {
|
||||
margin-bottom: 9px;
|
||||
|
||||
color: var(--muted);
|
||||
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
.method-card-id {
|
||||
color: var(--muted);
|
||||
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
.method-card-meta {
|
||||
display: flex;
|
||||
|
||||
flex-shrink: 0;
|
||||
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
|
||||
.method-meta-item {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
|
||||
.method-meta-item span {
|
||||
display: block;
|
||||
|
||||
margin-bottom: 5px;
|
||||
|
||||
color: var(--muted);
|
||||
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
.method-meta-item strong {
|
||||
color: var(--text);
|
||||
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
|
||||
.method-empty {
|
||||
padding: 50px 30px;
|
||||
|
||||
text-align: center;
|
||||
|
||||
background: var(--surface);
|
||||
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
|
||||
.method-empty-title {
|
||||
margin-bottom: 10px;
|
||||
|
||||
color: var(--text);
|
||||
|
||||
font-size: 21px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
|
||||
.method-empty-description {
|
||||
max-width: 520px;
|
||||
|
||||
margin: 0 auto 24px;
|
||||
|
||||
color: var(--muted);
|
||||
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
|
||||
grid-template-columns:
|
||||
repeat(
|
||||
auto-fit,
|
||||
minmax(180px, 1fr)
|
||||
);
|
||||
|
||||
gap: 18px;
|
||||
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
|
||||
.stat-card {
|
||||
background: var(--surface);
|
||||
|
||||
border: 1px solid var(--border);
|
||||
|
||||
border-radius: 10px;
|
||||
|
||||
padding: 18px;
|
||||
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
.stat-value {
|
||||
margin-bottom: 8px;
|
||||
|
||||
color: var(--accent);
|
||||
|
||||
font-size: 34px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
|
||||
.stat-title {
|
||||
color: var(--muted);
|
||||
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
|
||||
grid-template-columns:
|
||||
minmax(260px, 0.35fr)
|
||||
minmax(500px, 1fr);
|
||||
|
||||
gap: 24px;
|
||||
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
|
||||
border: 1px solid var(--border);
|
||||
|
||||
border-radius: 12px;
|
||||
|
||||
padding: 22px;
|
||||
|
||||
margin-bottom: 24px;
|
||||
|
||||
box-shadow:
|
||||
0 2px 10px rgba(0, 0, 0, 0.20);
|
||||
}
|
||||
|
||||
|
||||
.card h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
|
||||
color: var(--text);
|
||||
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
|
||||
max-height: 700px;
|
||||
|
||||
border: 1px solid var(--border);
|
||||
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
|
||||
th {
|
||||
background: var(--surface-light);
|
||||
|
||||
color: var(--text);
|
||||
|
||||
font-weight: 700;
|
||||
|
||||
text-align: left;
|
||||
|
||||
padding: 12px;
|
||||
|
||||
border-bottom: 2px solid var(--border);
|
||||
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
||||
vertical-align: top;
|
||||
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
|
||||
tbody tr:hover {
|
||||
background: rgba(105, 167, 255, 0.05);
|
||||
}
|
||||
|
||||
|
||||
.status-list {
|
||||
display: flex;
|
||||
|
||||
flex-direction: column;
|
||||
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
justify-content: space-between;
|
||||
|
||||
gap: 12px;
|
||||
|
||||
padding-bottom: 10px;
|
||||
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
|
||||
.status-row:last-child {
|
||||
padding-bottom: 0;
|
||||
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
min-height: 24px;
|
||||
|
||||
padding: 0 9px;
|
||||
|
||||
border-radius: 6px;
|
||||
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
background: var(--surface-light);
|
||||
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
|
||||
.badge-created,
|
||||
.badge-queued {
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
|
||||
.badge-running,
|
||||
.badge-partial {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
|
||||
.badge-completed {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
|
||||
.badge-failed,
|
||||
.badge-cancelled {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
|
||||
.empty-state {
|
||||
padding: 35px 20px;
|
||||
|
||||
color: var(--muted);
|
||||
|
||||
text-align: center;
|
||||
|
||||
border: 1px dashed var(--border);
|
||||
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 900px) {
|
||||
|
||||
.cracklab-header-inner {
|
||||
align-items: flex-start;
|
||||
|
||||
flex-direction: column;
|
||||
|
||||
gap: 12px;
|
||||
|
||||
padding-top: 14px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
|
||||
.cracklab-nav {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.cracklab-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.methods-page-header {
|
||||
align-items: stretch;
|
||||
|
||||
flex-direction: column;
|
||||
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
|
||||
.methods-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.method-card {
|
||||
align-items: stretch;
|
||||
|
||||
flex-direction: column;
|
||||
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
|
||||
.method-card-meta {
|
||||
width: 100%;
|
||||
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 600px) {
|
||||
|
||||
.cracklab-content {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
|
||||
.card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,95 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
>
|
||||
|
||||
<title>
|
||||
{% block title %}
|
||||
CrackLab
|
||||
{% endblock %}
|
||||
</title>
|
||||
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{{ url_for('cracklab.static', filename='cracklab.css') }}"
|
||||
>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<header class="cracklab-header">
|
||||
|
||||
<div class="cracklab-header-inner">
|
||||
|
||||
<a
|
||||
class="cracklab-brand"
|
||||
href="{{ url_for('cracklab.dashboard') }}"
|
||||
>
|
||||
<span class="cracklab-brand-title">
|
||||
WiFi GPS Mapper
|
||||
</span>
|
||||
|
||||
<span class="cracklab-brand-subtitle">
|
||||
CrackLab
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<nav class="cracklab-nav">
|
||||
|
||||
<a
|
||||
class="{% if active_page == 'dashboard' %}active{% endif %}"
|
||||
href="{{ url_for('cracklab.dashboard') }}"
|
||||
>
|
||||
Dashboard
|
||||
</a>
|
||||
|
||||
<a
|
||||
class="{% if active_page == 'jobs' %}active{% endif %}"
|
||||
href="{{ url_for('cracklab.jobs_page') }}"
|
||||
>
|
||||
Jobs
|
||||
</a>
|
||||
|
||||
<a
|
||||
class="{% if active_page == 'methods' %}active{% endif %}"
|
||||
href="{{ url_for('cracklab.methods_page') }}"
|
||||
>
|
||||
Methods
|
||||
</a>
|
||||
|
||||
<a
|
||||
class="{% if active_page == 'clients' %}active{% endif %}"
|
||||
href="{{ url_for('cracklab.clients_page') }}"
|
||||
>
|
||||
Clients
|
||||
</a>
|
||||
|
||||
<a
|
||||
class="{% if active_page == 'results' %}active{% endif %}"
|
||||
href="{{ url_for('cracklab.results_page') }}"
|
||||
>
|
||||
Results
|
||||
</a>
|
||||
|
||||
</nav>
|
||||
|
||||
</div>
|
||||
|
||||
</header>
|
||||
|
||||
<main class="cracklab-content">
|
||||
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
|
||||
</main>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,215 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
CrackLab — Dashboard
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1 class="page-title">
|
||||
CrackLab
|
||||
</h1>
|
||||
|
||||
<div class="page-description">
|
||||
Job and cracking coverage management.
|
||||
</div>
|
||||
|
||||
|
||||
<div class="stats-grid">
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
{{ methods_count }}
|
||||
</div>
|
||||
|
||||
<div class="stat-title">
|
||||
Methods
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
{{ jobs_count }}
|
||||
</div>
|
||||
|
||||
<div class="stat-title">
|
||||
Jobs
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
{{ active_jobs_count }}
|
||||
</div>
|
||||
|
||||
<div class="stat-title">
|
||||
Active Jobs
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
{{ clients_count }}
|
||||
</div>
|
||||
|
||||
<div class="stat-title">
|
||||
Clients
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
{{ results_count }}
|
||||
</div>
|
||||
|
||||
<div class="stat-title">
|
||||
Results
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="dashboard-grid">
|
||||
|
||||
<section class="card">
|
||||
|
||||
<h2>
|
||||
Job Status
|
||||
</h2>
|
||||
|
||||
<div class="status-list">
|
||||
|
||||
{% for status in (
|
||||
"CREATED",
|
||||
"QUEUED",
|
||||
"RUNNING",
|
||||
"PARTIAL",
|
||||
"COMPLETED",
|
||||
"FAILED",
|
||||
"CANCELLED"
|
||||
) %}
|
||||
|
||||
<div class="status-row">
|
||||
|
||||
<span>
|
||||
<span class="badge badge-{{ status|lower }}">
|
||||
{{ status }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<strong>
|
||||
{{ job_statuses.get(status, 0) }}
|
||||
</strong>
|
||||
|
||||
</div>
|
||||
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
<section class="card">
|
||||
|
||||
<h2>
|
||||
Recent Jobs
|
||||
</h2>
|
||||
|
||||
{% if recent_jobs %}
|
||||
|
||||
<div class="table-container">
|
||||
|
||||
<table>
|
||||
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Job</th>
|
||||
<th>Method</th>
|
||||
<th>Status</th>
|
||||
<th>Hashes</th>
|
||||
<th>Client</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
|
||||
{% for job in recent_jobs %}
|
||||
|
||||
<tr>
|
||||
|
||||
<td>
|
||||
<a
|
||||
href="{{ url_for(
|
||||
'cracklab.jobs_page'
|
||||
) }}"
|
||||
>
|
||||
{{ job["id"] }}
|
||||
</a>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{{ method_names.get(
|
||||
job["method_id"],
|
||||
job["method_id"]
|
||||
) }}
|
||||
<span class="muted">
|
||||
v{{ job["method_version"] }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<span
|
||||
class="badge badge-{{
|
||||
job["status"]|lower
|
||||
}}"
|
||||
>
|
||||
{{ job["status"] }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{{ job["hash_count"] }}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{% if job["client_id"] %}
|
||||
{{ client_names.get(
|
||||
job["client_id"],
|
||||
job["client_id"]
|
||||
) }}
|
||||
{% else %}
|
||||
<span class="muted">
|
||||
—
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
{% endfor %}
|
||||
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
|
||||
<div class="empty-state">
|
||||
No jobs have been issued yet.
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
CrackLab — Methods
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="methods-page">
|
||||
|
||||
<div class="methods-page-header">
|
||||
|
||||
<div>
|
||||
<h1 class="page-title">
|
||||
Methods
|
||||
</h1>
|
||||
|
||||
<div class="page-description">
|
||||
Рабочие методы перебора, сохранённые в базе данных.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if methods %}
|
||||
<a
|
||||
class="methods-button"
|
||||
href="{{ url_for('cracklab.method_create_page') }}"
|
||||
>
|
||||
+ Создать метод
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
{% if methods %}
|
||||
|
||||
<section class="methods-list">
|
||||
|
||||
{% for method in methods %}
|
||||
|
||||
<article class="method-card">
|
||||
|
||||
<div class="method-card-main">
|
||||
|
||||
<div class="method-card-title">
|
||||
{{ method.name }}
|
||||
</div>
|
||||
|
||||
{% if method.description %}
|
||||
<div class="method-card-description">
|
||||
{{ method.description }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="method-card-id">
|
||||
{{ method.id }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="method-card-meta">
|
||||
|
||||
<div class="method-meta-item">
|
||||
<span>Version</span>
|
||||
|
||||
<strong>
|
||||
{% if method.version %}
|
||||
{{ method.version }}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="method-meta-item">
|
||||
<span>Steps</span>
|
||||
|
||||
<strong>
|
||||
{{ method.steps_count }}
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="method-meta-item">
|
||||
<span>Created</span>
|
||||
|
||||
<strong>
|
||||
{{ method.created_at }}
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</article>
|
||||
|
||||
{% endfor %}
|
||||
|
||||
</section>
|
||||
|
||||
{% else %}
|
||||
|
||||
<section class="method-empty">
|
||||
|
||||
<div class="method-empty-title">
|
||||
Методов пока нет
|
||||
</div>
|
||||
|
||||
<div class="method-empty-description">
|
||||
Создайте первый метод, чтобы начать
|
||||
выдавать задания CrackLab.
|
||||
</div>
|
||||
|
||||
<a
|
||||
class="methods-button"
|
||||
href="{{ url_for('cracklab.method_create_page') }}"
|
||||
>
|
||||
+ Создать метод
|
||||
</a>
|
||||
|
||||
</section>
|
||||
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
CrackLab — {{ title }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1 class="page-title">
|
||||
{{ title }}
|
||||
</h1>
|
||||
|
||||
<div class="page-description">
|
||||
{{ description }}
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
|
||||
<div class="empty-state">
|
||||
This section is under development.
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user