Update public source

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