ДЕПОНИРУЕМЫЙ ЛИСТИНГ ИСХОДНОГО КОДА
CIFRA SECURE — Программный комплекс кибербезопасности и облачного мониторинга
============================================================================
Дата генерации: 2026-10-14 01:57 UTC
Модулей: 17
============================================================================
======================================================================МОДУЛЬ: Ядро лицензированияФАЙЛ: app/main.py======================================================================
# AV Platform Core — точка входа FastAPI
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, FileResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
from app.config import get_settings
from app.database import init_db
from app.routers import admin, cabinet, client, cloud, vault, recovery
from app.i18n import resolve_lang, t
settings = get_settings()
# Путь к статике (веб-панель)
STATIC_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Инициализация при старте: создание таблиц."""
await init_db()
yield
app = FastAPI(
title=settings.app_name,
description="Серверное ядро лицензирования и облачных сервисов (GLOBAL, без Geo-IP)",
version="1.0.0",
lifespan=lifespan,
)
# CORS (для веб-панели)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Глобальный обработчик ошибок для локализации
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
lang = resolve_lang(request.headers.get("accept-language"))
return JSONResponse(
status_code=500,
content={"detail": t("error.bad_request", lang)},
)
# Подключение роутеров
app.include_router(admin.router, prefix=settings.api_v1_prefix)
app.include_router(cabinet.router, prefix=settings.api_v1_prefix)
app.include_router(client.router, prefix=settings.api_v1_prefix)
app.include_router(cloud.router, prefix=settings.api_v1_prefix)
app.include_router(vault.router, prefix=settings.api_v1_prefix)
app.include_router(recovery.router, prefix=settings.api_v1_prefix)
@app.get("/health")
async def health():
"""Health-check эндпоинт."""
return {
"status": "ok",
"service": settings.app_name,
"region": "GLOBAL",
}
# ── Веб-панель (ESET HOME style) ──────────────────────────────
@app.get("/", include_in_schema=False)
async def root():
"""Корневой путь → личный кабинет (index.html)."""
index_path = os.path.join(STATIC_DIR, "index.html")
if os.path.exists(index_path):
return FileResponse(index_path, media_type="text/html")
# Фолбэк — JSON, если статика ещё не положена
return {
"service": settings.app_name,
"version": "1.0.0",
"region": "GLOBAL",
"region_label": "Global (Worldwide Activation)",
"docs": "/docs",
"api_prefix": settings.api_v1_prefix,
}
@app.get("/admin", include_in_schema=False)
async def admin_ui():
"""Админ-панель (admin.html)."""
admin_path = os.path.join(STATIC_DIR, "admin.html")
if os.path.exists(admin_path):
return FileResponse(admin_path, media_type="text/html")
return {"detail": "Admin UI not built yet."}
# ── Юридический пакет (Роспатент / ФИПС) ─────────────────────
LEGAL_DIR = os.path.join(os.path.dirname(STATIC_DIR), "legal")
LEGAL_DOCS = {
"eula": ("EULA_RU_EN.md", "Лицензионное соглашение (EULA) — RU/EN", "text/markdown; charset=utf-8"),
"privacy": ("PRIVACY_ZERO_TELEMETRY.md", "Политика конфиденциальности (Zero-Telemetry)", "text/markdown; charset=utf-8"),
"sbom": ("SBOM.md", "SBOM — перечень компонентов ПО", "text/markdown; charset=utf-8"),
"rospatent": ("ROSPATENT_PACKAGE.md", "Пакет данных для ФИПС (Роспатент)", "text/markdown; charset=utf-8"),
"deposition": ("source_code_deposition.txt", "Депонирование исходного кода", "text/plain; charset=utf-8"),
"listing": ("cifra_secure_listing.txt", "Листинг исходного кода (депозит)", "text/plain; charset=utf-8"),
}
@app.get("/legal", include_in_schema=False)
async def legal_index():
"""Индекс юридических документов CIFRA SECURE."""
rows = "".join(
f'
{v[1]} — {v[0]}'
for k, v in LEGAL_DOCS.items()
)
html = f"""
CIFRA SECURE — Юридические документы
CIFRA SECURE — Юридические документы
Программный комплекс кибербезопасности и облачного мониторинга «CIFRA SECURE»
← На главную
"""
return HTMLResponse(html)
@app.get("/legal/{doc_key}", include_in_schema=False)
async def legal_doc(doc_key: str):
"""Отдать конкретный юридический документ."""
if doc_key not in LEGAL_DOCS:
return JSONResponse(status_code=404, content={"detail": "Документ не найден"})
filename, _, media = LEGAL_DOCS[doc_key]
path = os.path.join(LEGAL_DIR, filename)
if os.path.exists(path):
return FileResponse(path, media_type=media, filename=filename)
return JSONResponse(status_code=404, content={"detail": "Файл не смонтирован"})
# ── Скачивание агентов (привязано к кнопкам «Скачать защиту») ──
@app.get("/download/windows", include_in_schema=False)
async def download_windows():
"""Скачать Windows-агент.
Отдаёт собранный .exe (если есть) или архив с исходниками.
Правило: рекомендуется активировать ключ в день покупки.
"""
# Ищем собранный exe
exe_path = os.path.join(
os.path.dirname(STATIC_DIR), "windows-agent", "dist", "AVPlatformAgent.exe"
)
if os.path.exists(exe_path):
return FileResponse(
exe_path,
media_type="application/octet-stream",
filename="AVPlatformAgent.exe",
)
# Фолбэк — zip с исходниками агента
import shutil
import tempfile
agent_src = os.path.join(os.path.dirname(STATIC_DIR), "windows-agent")
if os.path.isdir(agent_src):
tmp = tempfile.mkdtemp()
zip_base = os.path.join(tmp, "AVPlatformAgent")
shutil.make_archive(zip_base, "zip", agent_src)
return FileResponse(
zip_base + ".zip",
media_type="application/zip",
filename="AVPlatformAgent.zip",
)
return JSONResponse(
status_code=404,
content={"detail": "Windows agent build not found."},
)
@app.get("/download/android", include_in_schema=False)
async def download_android():
"""Скачать Android-агента (плейсхолдер до сборки APK)."""
android_src = os.path.join(os.path.dirname(STATIC_DIR), "android-agent")
if os.path.isdir(android_src):
import shutil
import tempfile
tmp = tempfile.mkdtemp()
zip_base = os.path.join(tmp, "AVPlatformAgent-Android")
shutil.make_archive(zip_base, "zip", android_src)
return FileResponse(
zip_base + ".zip",
media_type="application/zip",
filename="AVPlatformAgent-Android.zip",
)
return JSONResponse(
status_code=404,
content={"detail": "Android agent not ready yet. Coming soon."},
)
# Раздача остальной статики (JS, манифест, иконки) из /static
if os.path.isdir(STATIC_DIR):
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
======================================================================МОДУЛЬ: Модели данныхФАЙЛ: app/models.py======================================================================
# SQLAlchemy ORM-модели (все таблицы)
import uuid
from datetime import datetime, timezone
from sqlalchemy import (
String, Integer, Boolean, DateTime, Text,
ForeignKey, Index, UniqueConstraint
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def _uuid() -> str:
return str(uuid.uuid4())
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
class License(Base):
"""Лицензия (ключ) — основная сущность.
Все ключи ГЛОБАЛЬНЫЕ (region=GLOBAL, is_global=True).
Статус UNACTIVATED → ACTIVE (при первой активации) → EXPIRED.
Статус MERGED — ключ был объединён в другой (stack-key).
Статус REVOKED — ключ отозван админом.
"""
__tablename__ = "licenses"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
key_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
# Полный ключ (нужен для Zero-Telemetry Recovery — возврат при восстановлении).
# В боевой версии рекомендуется шифровать at-rest серверным ключом.
key_plain: Mapped[str | None] = mapped_column(String(32), nullable=True)
key_masked: Mapped[str] = mapped_column(String(32))
edition: Mapped[str] = mapped_column(String(16)) # STANDARD / PLUS / PREMIUM
duration_days: Mapped[int] = mapped_column(Integer)
max_devices: Mapped[int] = mapped_column(Integer)
# Глобальный регион (без Geo-IP)
region: Mapped[str] = mapped_column(String(16), default="GLOBAL")
region_label: Mapped[str] = mapped_column(String(64), default="Global (Worldwide Activation)")
is_global: Mapped[bool] = mapped_column(Boolean, default=True)
status: Mapped[str] = mapped_column(String(16), default="UNACTIVATED", index=True)
activated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Настройки UI
ui_theme: Mapped[str] = mapped_column(String(8), default="dark") # dark / light
language: Mapped[str] = mapped_column(String(4), default="ru") # ru / en
# Восстановление ключа без Email (Zero-Telemetry Recovery)
# Хранит ТОЛЬКО SHA-256 (с солью av_recovery_v2_salt:) — сам код/фраза не хранятся.
recovery_hash: Mapped[str | None] = mapped_column(String(64), unique=True, index=True, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
# Связи
devices: Mapped[list["Device"]] = relationship(
back_populates="license", cascade="all, delete-orphan"
)
invites: Mapped[list["Invite"]] = relationship(
back_populates="license", cascade="all, delete-orphan"
)
vault: Mapped["PasswordVault | None"] = relationship(
back_populates="license", cascade="all, delete-orphan", uselist=False
)
class Device(Base):
"""Устройство, привязанное к лицензии."""
__tablename__ = "devices"
__table_args__ = (
UniqueConstraint("license_id", "device_id", name="uq_license_device"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
license_id: Mapped[str] = mapped_column(String(36), ForeignKey("licenses.id", ondelete="CASCADE"), index=True)
device_id: Mapped[str] = mapped_column(String(128)) # MachineGUID / Android ID
platform: Mapped[str] = mapped_column(String(16)) # WINDOWS / ANDROID
device_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
activated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
last_heartbeat_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
license: Mapped["License"] = relationship(back_populates="devices")
class Invite(Base):
"""Одноразовый инвайт-код для привязки устройств без раскрытия мастер-ключа."""
__tablename__ = "invites"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
license_id: Mapped[str] = mapped_column(String(36), ForeignKey("licenses.id", ondelete="CASCADE"), index=True)
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
token_masked: Mapped[str] = mapped_column(String(32))
max_uses: Mapped[int] = mapped_column(Integer, default=1)
uses_count: Mapped[int] = mapped_column(Integer, default=0)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
region: Mapped[str] = mapped_column(String(16), default="GLOBAL")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
license: Mapped["License"] = relationship(back_populates="invites")
class PasswordVault(Base):
"""Хранилище паролей (Zero-Knowledge, зашифрованный слепок)."""
__tablename__ = "password_vaults"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
license_id: Mapped[str] = mapped_column(String(36), ForeignKey("licenses.id", ondelete="CASCADE"), unique=True, index=True)
encrypted_blob: Mapped[str] = mapped_column(Text)
blob_hash: Mapped[str] = mapped_column(String(64)) # SHA-256 для целостности
entry_count: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
license: Mapped["License"] = relationship(back_populates="vault")
class ThreatSignatures(Base):
"""Таблица сигнатур угроз для облачного лукапа."""
__tablename__ = "threat_signatures"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
sha256: Mapped[str] = mapped_column(String(64), unique=True, index=True)
threat_name: Mapped[str] = mapped_column(String(256))
severity: Mapped[str] = mapped_column(String(16), default="HIGH") # LOW / MEDIUM / HIGH / CRITICAL
family: Mapped[str | None] = mapped_column(String(128), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
class SpamPhones(Base):
"""База спам-номеров (Who Calls / Антиспам звонков).
category: SCAM / BANK_SPAM / ADS / SAFE
Хранится нормализованный номер или префикс (для RU +7..., международные).
"""
__tablename__ = "spam_phones"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
phone_normalized: Mapped[str] = mapped_column(String(32), unique=True, index=True)
category: Mapped[str] = mapped_column(String(16), default="SCAM") # SCAM / BANK_SPAM / ADS / SAFE
description_ru: Mapped[str | None] = mapped_column(String(256), nullable=True)
description_en: Mapped[str | None] = mapped_column(String(256), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
class KeyMergeHistory(Base):
"""История наложения/объединения ключей (stack-key)."""
__tablename__ = "key_merge_history"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
target_license_id: Mapped[str] = mapped_column(String(36), ForeignKey("licenses.id", ondelete="CASCADE"), index=True)
source_license_id: Mapped[str] = mapped_column(String(36), ForeignKey("licenses.id", ondelete="CASCADE"), index=True)
source_key_masked: Mapped[str] = mapped_column(String(32))
old_expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
new_expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
added_days: Mapped[int] = mapped_column(Integer)
old_edition: Mapped[str] = mapped_column(String(16))
new_edition: Mapped[str] = mapped_column(String(16))
old_max_devices: Mapped[int] = mapped_column(Integer)
new_max_devices: Mapped[int] = mapped_column(Integer)
merged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
======================================================================МОДУЛЬ: Схемы APIФАЙЛ: app/schemas.py======================================================================
# Pydantic v2 — схемы запросов и ответов
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field, ConfigDict
# ===================== ENUM-константы =====================
# Новая коммерческая сетка 5 тарифов + алиасы старых 3 (для совместимости тестов)
EditionLiteral = Literal[
"ANTIVIRUS", # AVIR- (алиас STANDARD)
"INTERNET_SECURITY", # ISEC- (алиас PLUS)
"TOTAL_SECURITY", # TOTL- (алиас PREMIUM)
"ULTIMATE", # ULTM-
"SMALL_OFFICE", # BIZS-
"STANDARD", "PLUS", "PREMIUM", # алиасы
]
PlatformLiteral = Literal["WINDOWS", "ANDROID"]
ThemeLiteral = Literal["dark", "light"]
LanguageLiteral = Literal["ru", "en"]
StatusLiteral = Literal["UNACTIVATED", "ACTIVE", "EXPIRED", "MERGED", "REVOKED"]
HashStatusLiteral = Literal["CLEAN", "MALWARE", "UNKNOWN"]
# ===================== Базовые ответы =====================
class RegionMixin(BaseModel):
"""Mixin: все ответы с ключом содержат ГЛОБАЛЬНЫЙ регион."""
region: Literal["GLOBAL"] = "GLOBAL"
region_label: str = "Global (Worldwide Activation)"
is_global: bool = True
# ===================== Лицензия =====================
class LicenseOut(RegionMixin):
"""Ответ с данными лицензии."""
model_config = ConfigDict(from_attributes=True)
id: str
key_masked: str
edition: EditionLiteral
duration_days: int
max_devices: int
status: StatusLiteral
activated_at: datetime | None
expires_at: datetime | None
ui_theme: ThemeLiteral
language: LanguageLiteral
created_at: datetime
class LicenseCreatedOut(RegionMixin):
"""Ответ при генерации нового ключа (админ)."""
id: str
license_key: str # Полный ключ — возвращается ТОЛЬКО при генерации
key_masked: str
edition: EditionLiteral
duration_days: int
max_devices: int
status: StatusLiteral
# ===================== Админ: генерация ключей =====================
class GenerateKeyRequest(BaseModel):
"""Запрос на генерацию нового ключа."""
edition: EditionLiteral = "STANDARD"
duration_days: int = Field(default=365, ge=1, le=3650)
max_devices: int = Field(default=1, ge=1, le=999)
count: int = Field(default=1, ge=1, le=100)
class GenerateKeyResponse(BaseModel):
"""Ответ со списком сгенерированных ключей."""
keys: list[LicenseCreatedOut]
generated: int
# ===================== Активация =====================
class ActivateRequest(BaseModel):
"""Запрос активации по ключу или инвайту."""
license_key: str | None = None
invite_token: str | None = None
device_id: str = Field(..., min_length=1, max_length=128)
platform: PlatformLiteral
device_name: str | None = Field(default=None, max_length=128)
class ActivateResponse(RegionMixin):
"""Ответ активации."""
license: LicenseOut
device_id: str
message: str
# ===================== Heartbeat =====================
class HeartbeatRequest(BaseModel):
"""Запрос heartbeat (проверка подписки)."""
license_key: str
device_id: str
class HeartbeatResponse(RegionMixin):
"""Ответ heartbeat."""
status: StatusLiteral
expires_at: datetime | None
days_left: int | None
message: str
# ===================== Устройства =====================
class DeviceOut(BaseModel):
"""Данные устройства."""
model_config = ConfigDict(from_attributes=True)
id: str
device_id: str
platform: PlatformLiteral
device_name: str | None
is_active: bool
activated_at: datetime
last_heartbeat_at: datetime | None
class DeviceListOut(BaseModel):
"""Список устройств."""
devices: list[DeviceOut]
total: int
max_devices: int
class UnbindRequest(BaseModel):
"""Запрос отвязки устройства."""
license_key: str
device_id: str
class UnbindResponse(BaseModel):
"""Ответ отвязки."""
success: bool
message: str
# ===================== Cabinet: stack-key =====================
class StackKeyRequest(BaseModel):
"""Запрос на наложение/объединение ключей."""
current_key: str
new_key: str
class StackKeyResponse(RegionMixin):
"""Ответ stack-key."""
license: LicenseOut
added_days: int
old_expires_at: datetime
new_expires_at: datetime
edition_upgraded: bool
devices_upgraded: bool
message: str
# ===================== Cabinet: switch-key (upgrade/downgrade) =====================
class SwitchKeyRequest(BaseModel):
"""Запрос двустороннего переключения тарифа (Upgrade & Downgrade)."""
current_key: str
new_key: str
class SwitchKeyResponse(RegionMixin):
"""Ответ switch-key."""
license: LicenseOut
direction: Literal["upgrade", "downgrade"]
old_edition: str
new_edition: str
added_days: int
new_expires_at: datetime
vault_read_only: bool
message: str
# ===================== Cabinet: preferences =====================
class PreferencesRequest(BaseModel):
"""Запрос сохранения настроек UI."""
ui_theme: ThemeLiteral | None = None
language: LanguageLiteral | None = None
class PreferencesResponse(RegionMixin):
"""Ответ с обновлёнными настройками."""
ui_theme: ThemeLiteral
language: LanguageLiteral
message: str
# ===================== Cloud: hash lookup =====================
class CloudLookupRequest(BaseModel):
"""Запрос облачной проверки хэшей."""
hashes: list[str] = Field(..., min_length=1, max_length=100)
class HashResult(BaseModel):
"""Результат проверки одного хэша."""
sha256: str
status: HashStatusLiteral
threat_name: str | None
class CloudLookupResponse(RegionMixin):
"""Ответ облачного лукапа."""
results: list[HashResult]
checked: int
# ===================== Vault: синхронизация =====================
class VaultSyncRequest(BaseModel):
"""Запрос загрузки зашифрованного слепка паролей."""
encrypted_blob: str
blob_hash: str = Field(..., min_length=64, max_length=64)
entry_count: int = Field(..., ge=0)
class VaultSyncOut(RegionMixin):
"""Ответ синхронизации хранилища."""
encrypted_blob: str | None
blob_hash: str | None
entry_count: int
updated_at: datetime | None
message: str
# ===================== Приглашения (инвайты) =====================
class CreateInviteRequest(BaseModel):
"""Запрос создания инвайт-кода."""
license_key: str
max_uses: int = Field(default=1, ge=1, le=999)
class InviteOut(RegionMixin):
"""Ответ с данными инвайта."""
id: str
token_masked: str
max_uses: int
uses_count: int
is_active: bool
created_at: datetime
expires_at: datetime | None
class CreateInviteResponse(RegionMixin):
"""Ответ при создании инвайта (токен возвращается ТОЛЬКО здесь)."""
invite: InviteOut
invite_token: str # Полный токен — возвращается только при создании
message: str
# ===================== Health =====================
class HealthOut(BaseModel):
"""Ответ health-check."""
status: str
service: str
region: str
# ===================== Спам-номера (Who Calls / Антиспам) =====================
class PhoneAddRequest(BaseModel):
"""Запрос добавления спам-номера в базу."""
phone: str = Field(..., min_length=3, max_length=32)
category: Literal["SCAM", "BANK_SPAM", "ADS", "SAFE"] = "SCAM"
description_ru: str | None = Field(default=None, max_length=256)
description_en: str | None = Field(default=None, max_length=256)
class PhoneLookupRequest(BaseModel):
"""Запрос проверки входящего номера (Who Calls)."""
phone: str = Field(..., min_length=3, max_length=32)
class PhoneLookupResponse(RegionMixin):
"""Ответ проверки номера."""
phone: str
status: Literal["CLEAN", "SPAM"]
category: str | None
description: str | None
message: str
# ===================== Восстановление ключа (Zero-Telemetry Recovery) =====================
class RestoreByHwidRequest(BaseModel):
"""Запрос авто-восстановления по железу (HWID)."""
device_id: str = Field(..., min_length=1, max_length=128)
platform: PlatformLiteral = "WINDOWS"
class GenerateRecoveryCodeResponse(BaseModel):
"""Ответ: сгенерированное число восстановления (показывается 1 раз)."""
code: str
message: str
class SetRecoveryHashRequest(BaseModel):
"""Запрос: установить собственную связку (слово + 4 цифры)."""
secret_word: str = Field(..., min_length=3, max_length=64)
pin_digits: str = Field(..., min_length=4, max_length=4, pattern=r"^\d{4}$")
class RecoverKeyRequest(BaseModel):
"""Запрос восстановления: ИЛИ code (XXX-XXX-XX), ИЛИ secret_word + pin_digits."""
code: str | None = Field(default=None, max_length=32)
secret_word: str | None = Field(default=None, max_length=64)
pin_digits: str | None = Field(default=None, max_length=4)
class RecoverKeyResponse(RegionMixin):
"""Ответ восстановления: полный ключ + профиль лицензии."""
license_key: str
edition: str
duration_days: int
max_devices: int
status: str
expires_at: datetime | None
message: str
======================================================================МОДУЛЬ: Восстановление ключаФАЙЛ: app/routers/recovery.py======================================================================
# -*- coding: utf-8 -*-
"""Роутер: восстановление ключа без Email (Zero-Telemetry Recovery).
Эндпоинты:
POST /client/restore-by-hwid — авто-восстановление по железу (0 кликов)
POST /cabinet/generate-recovery-code — сгенерировать число XXX-XXX-XX (Вариант А)
POST /cabinet/set-recovery-hash — своя связка слово+4 цифры (Вариант Б)
POST /cabinet/recover-key — восстановить по коду/связке (rate-limit Redis)
GET /cabinet/recovery-kit.txt — скачать памятку с ключом
"""
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import PlainTextResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from redis.asyncio import Redis
from app.database import get_db, get_redis
from app.deps import get_language, get_license_by_key_header
from app.i18n import LanguageCode, t
from app.models import License, Device
from app.schemas import (
RestoreByHwidRequest,
GenerateRecoveryCodeResponse,
SetRecoveryHashRequest,
RecoverKeyRequest,
RecoverKeyResponse,
)
from app.services import recovery_service
router = APIRouter(tags=["recovery"])
def _now_active(lic: License) -> bool:
"""Лицензия активна и не истекла."""
from datetime import datetime, timezone
if lic.status != "ACTIVE":
return False
if lic.expires_at is None:
return True
return lic.expires_at > datetime.now(timezone.utc)
@router.post("/client/restore-by-hwid", response_model=RecoverKeyResponse)
async def restore_by_hwid(
payload: RestoreByHwidRequest,
db: AsyncSession = Depends(get_db),
lang: LanguageCode = Depends(get_language),
):
"""Авто-восстановление по железу: hwid → активная лицензия → вернуть ключ."""
# hwid может встречаться у нескольких лицензий — берём последнее активное устройство
result = await db.execute(
select(Device)
.where(Device.device_id == payload.device_id,
Device.platform == payload.platform)
.order_by(Device.activated_at.desc())
)
devices = result.scalars().all()
if not devices:
raise HTTPException(status_code=404, detail=t("recovery.not_found", lang))
lic = None
for dev in devices:
lic_result = await db.execute(select(License).where(License.id == dev.license_id))
candidate = lic_result.scalar_one_or_none()
if candidate is not None and _now_active(candidate):
lic = candidate
break
if lic is None:
raise HTTPException(status_code=404, detail=t("recovery.not_found", lang))
return _recovery_response(lic, t("recovery.restored_by_hwid", lang))
@router.post("/cabinet/generate-recovery-code", response_model=GenerateRecoveryCodeResponse)
async def generate_recovery_code(
db: AsyncSession = Depends(get_db),
redis_client: Redis = Depends(get_redis),
license_obj: License = Depends(get_license_by_key_header),
lang: LanguageCode = Depends(get_language),
):
"""Вариант А: сгенерировать уникальное число XXX-XXX-XX (показать 1 раз)."""
for _ in range(20): # защита от коллизий
code = recovery_service.generate_numeric_code()
h = recovery_service.hash_recovery(code)
# Проверка коллизии: хэш не занят другим ключом
existing = await db.execute(
select(License).where(License.recovery_hash == h,
License.id != license_obj.id)
)
if existing.scalar_one_or_none() is None:
license_obj.recovery_hash = h
await db.commit()
return GenerateRecoveryCodeResponse(
code=code,
message=t("recovery.code_generated", lang),
)
raise HTTPException(status_code=500, detail=t("recovery.code_fail", lang))
@router.post("/cabinet/set-recovery-hash")
async def set_recovery_hash(
payload: SetRecoveryHashRequest,
db: AsyncSession = Depends(get_db),
license_obj: License = Depends(get_license_by_key_header),
lang: LanguageCode = Depends(get_language),
):
"""Вариант Б: своя связка (слово + 4 цифры). 409 если хэш занят."""
h = recovery_service.build_phrase_hash(payload.secret_word, payload.pin_digits)
existing = await db.execute(
select(License).where(License.recovery_hash == h,
License.id != license_obj.id)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(status_code=409, detail=t("recovery.hash_taken", lang))
license_obj.recovery_hash = h
await db.commit()
return {"message": t("recovery.hash_set", lang)}
@router.post("/cabinet/recover-key", response_model=RecoverKeyResponse)
async def recover_key(
payload: RecoverKeyRequest,
request: Request,
db: AsyncSession = Depends(get_db),
redis_client: Redis = Depends(get_redis),
lang: LanguageCode = Depends(get_language),
):
"""Восстановить ключ по коду XXX-XXX-XX ИЛИ связке слово+цифры. Rate-limit 5/10мин."""
# Определяем клиентский идентификатор для rate-limit (по IP)
client_id = request.client.host if request.client else "unknown"
if not await recovery_service.check_rate_limit(redis_client, client_id):
raise HTTPException(status_code=429, detail=t("recovery.rate_limited", lang))
# Формируем хэш из ввода
if payload.code:
h = recovery_service.hash_recovery(recovery_service.normalize_code(payload.code))
elif payload.secret_word and payload.pin_digits:
h = recovery_service.build_phrase_hash(payload.secret_word, payload.pin_digits)
else:
raise HTTPException(status_code=400, detail=t("recovery.bad_input", lang))
await recovery_service.register_attempt(redis_client, client_id)
result = await db.execute(select(License).where(License.recovery_hash == h))
lic = result.scalar_one_or_none()
if lic is None or not _now_active(lic):
raise HTTPException(status_code=404, detail=t("recovery.not_found", lang))
remaining = await recovery_service.remaining_attempts(redis_client, client_id)
resp = _recovery_response(lic, t("recovery.restored", lang))
return resp
@router.get("/cabinet/recovery-kit.txt")
async def recovery_kit(
db: AsyncSession = Depends(get_db),
license_obj: License = Depends(get_license_by_key_header),
):
"""Скачать памятку с ключом (.txt)."""
lines = [
"=== CIFRA SECURE | СЕРТИФИКАТ ЛИЦЕНЗИИ И ВОССТАНОВЛЕНИЯ ===",
"",
"Ваш ключ активации (сохраните в надёжном месте):",
f" {license_obj.key_plain or license_obj.key_masked}",
"",
"Способы восстановления ключа (без Email, 100% анонимно):",
" 1) Авто-по железу (HWID): запустите агент без ключа на том же ПК.",
" 2) По числу восстановления XXX-XXX-XX (если генерировали).",
" 3) По связке: ваше слово + 4 цифры (если задавали).",
"",
"⚠️ ПАМЯТКА: Обязательно сохраните свой ключ активации и (или)",
"число/фразу восстановления! Из-за политики 100% анонимности",
"(Zero-Telemetry) мы не храним ваши личные данные.",
"",
"Активируйте ключ в день покупки.",
"CIFRA SECURE • Global Edition",
]
return PlainTextResponse(
"\n".join(lines),
media_type="text/plain; charset=utf-8",
headers={"Content-Disposition": "attachment; filename=CIFRA_SECURE_KEY_BACKUP.txt"},
)
def _recovery_response(lic: License, message: str) -> RecoverKeyResponse:
return RecoverKeyResponse(
license_key=lic.key_plain or lic.key_masked,
edition=lic.edition,
duration_days=lic.duration_days,
max_devices=lic.max_devices,
status=lic.status,
expires_at=lic.expires_at,
message=message,
region="GLOBAL",
region_label="Global (Worldwide Activation)",
is_global=True,
)
======================================================================МОДУЛЬ: Лицензионный сервисФАЙЛ: app/services/license_service.py======================================================================
# Сервис лицензий: активация, heartbeat, stack-key, switch-key, инвайты
from datetime import datetime, timedelta, timezone
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import (
License, Device, Invite, KeyMergeHistory,
)
from app.security import (
generate_license_key, generate_invite_token,
hash_key, mask_key, mask_token,
)
from app.config import get_settings
settings = get_settings()
# Ранг тарифов (для upgrade/downgrade и stack-key).
# Алиасы старых тарифов маппим на новые.
EDITION_RANK = {
"ANTIVIRUS": 1, "STANDARD": 1,
"INTERNET_SECURITY": 2, "PLUS": 2,
"TOTAL_SECURITY": 3, "PREMIUM": 3,
"ULTIMATE": 4,
"SMALL_OFFICE": 5,
}
def canonical_edition(edition: str) -> str:
"""Алиас → каноническое имя нового тарифа (для отображения)."""
return {
"STANDARD": "ANTIVIRUS",
"PLUS": "INTERNET_SECURITY",
"PREMIUM": "TOTAL_SECURITY",
}.get(edition, edition)
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
async def create_license(
db: AsyncSession,
edition: str,
duration_days: int,
max_devices: int,
) -> tuple[License, str]:
"""Создать новый ключ (UNACTIVATED, GLOBAL)."""
raw_key = generate_license_key(edition)
key_hash = hash_key(raw_key)
license_obj = License(
key_hash=key_hash,
key_plain=raw_key,
key_masked=mask_key(raw_key),
edition=edition,
duration_days=duration_days,
max_devices=max_devices,
region="GLOBAL",
region_label="Global (Worldwide Activation)",
is_global=True,
status="UNACTIVATED",
)
db.add(license_obj)
await db.commit()
await db.refresh(license_obj)
return license_obj, raw_key
async def get_license_by_raw_key(
db: AsyncSession,
raw_key: str,
) -> License | None:
"""Найти лицензию по сырому ключу."""
key_hash = hash_key(raw_key)
result = await db.execute(
select(License).where(License.key_hash == key_hash)
)
return result.scalar_one_or_none()
async def activate_license(
db: AsyncSession,
license_key: str | None,
invite_token: str | None,
device_id: str,
platform: str,
device_name: str | None,
) -> tuple[License, Device]:
"""Активировать лицензию по ключу или инвайту.
- Если UNACTIVATED → ставит activated_at=now, expires_at=now+duration_days, статус=ACTIVE.
- Если ACTIVE → проверяет лимит слотов и добавляет устройство.
- Если EXPIRED / REVOKED / MERGED → ошибка.
- Если устройство уже привязано → обновляет heartbeat.
"""
if license_key:
license_obj = await get_license_by_raw_key(db, license_key)
if license_obj is None:
raise ValueError("license.not_found")
invite_obj = None
elif invite_token:
invite_hash = hash_key(invite_token)
invite_result = await db.execute(
select(Invite).where(Invite.token_hash == invite_hash)
)
invite_obj = invite_result.scalar_one_or_none()
if invite_obj is None:
raise ValueError("invite.not_found")
if not invite_obj.is_active:
raise ValueError("invite.used_up")
if invite_obj.expires_at and invite_obj.expires_at < _utcnow():
raise ValueError("invite.expired")
license_obj = await db.get(License, invite_obj.license_id)
if license_obj is None:
raise ValueError("license.not_found")
else:
raise ValueError("auth.key_required")
# Проверка статуса лицензии
if license_obj.status == "REVOKED":
raise ValueError("license.revoked")
if license_obj.status == "MERGED":
raise ValueError("license.merged")
if license_obj.status == "EXPIRED":
raise ValueError("license.expired")
# Первая активация — запуск отсчёта срока
if license_obj.status == "UNACTIVATED":
now = _utcnow()
license_obj.activated_at = now
license_obj.expires_at = now + timedelta(days=license_obj.duration_days)
license_obj.status = "ACTIVE"
await db.flush()
# Проверка лимита устройств
existing_device_result = await db.execute(
select(Device).where(
Device.license_id == license_obj.id,
Device.device_id == device_id,
)
)
existing_device = existing_device_result.scalar_one_or_none()
if existing_device is None:
# Считаем активные устройства
active_count_result = await db.execute(
select(func.count(Device.id)).where(
Device.license_id == license_obj.id,
Device.is_active == True, # noqa: E712
)
)
active_count = active_count_result.scalar() or 0
if active_count >= license_obj.max_devices:
raise ValueError("device.limit_reached")
new_device = Device(
license_id=license_obj.id,
device_id=device_id,
platform=platform,
device_name=device_name,
is_active=True,
activated_at=_utcnow(),
last_heartbeat_at=_utcnow(),
)
db.add(new_device)
device_obj = new_device
else:
# Устройство уже привязано — обновляем heartbeat
existing_device.last_heartbeat_at = _utcnow()
existing_device.is_active = True
device_obj = existing_device
# Увеличиваем счётчик использований инвайта
if invite_obj is not None:
invite_obj.uses_count += 1
if invite_obj.uses_count >= invite_obj.max_uses:
invite_obj.is_active = False
await db.commit()
await db.refresh(license_obj)
await db.refresh(device_obj)
return license_obj, device_obj
async def heartbeat(
db: AsyncSession,
license_key: str,
device_id: str,
) -> dict:
"""Heartbeat: обновить last_heartbeat_at, вернуть статус подписки."""
license_obj = await get_license_by_raw_key(db, license_key)
if license_obj is None:
raise ValueError("license.not_found")
if license_obj.status == "REVOKED":
raise ValueError("license.revoked")
if license_obj.status == "MERGED":
raise ValueError("license.merged")
if license_obj.status == "UNACTIVATED":
raise ValueError("license.unactivated")
if license_obj.status == "EXPIRED":
raise ValueError("license.expired")
# Проверка срока
if license_obj.expires_at and license_obj.expires_at < _utcnow():
license_obj.status = "EXPIRED"
await db.commit()
raise ValueError("license.expired")
# Обновляем heartbeat устройства
device_result = await db.execute(
select(Device).where(
Device.license_id == license_obj.id,
Device.device_id == device_id,
)
)
device_obj = device_result.scalar_one_or_none()
if device_obj is not None:
device_obj.last_heartbeat_at = _utcnow()
await db.commit()
# Считаем дни до истечения
days_left = None
if license_obj.expires_at:
delta = license_obj.expires_at - _utcnow()
days_left = max(0, delta.days)
return {
"status": license_obj.status,
"expires_at": license_obj.expires_at,
"days_left": days_left,
}
async def unbind_device(
db: AsyncSession,
license_key: str,
device_id: str,
) -> bool:
"""Отвязать устройство от лицензии."""
license_obj = await get_license_by_raw_key(db, license_key)
if license_obj is None:
raise ValueError("license.not_found")
device_result = await db.execute(
select(Device).where(
Device.license_id == license_obj.id,
Device.device_id == device_id,
)
)
device_obj = device_result.scalar_one_or_none()
if device_obj is None:
raise ValueError("device.not_found")
await db.delete(device_obj)
await db.commit()
return True
async def list_devices(
db: AsyncSession,
license_obj: License,
) -> list[Device]:
"""Список устройств лицензии."""
result = await db.execute(
select(Device).where(Device.license_id == license_obj.id)
)
return list(result.scalars().all())
async def stack_key(
db: AsyncSession,
current_key: str,
new_key: str,
) -> tuple[License, KeyMergeHistory, bool, bool]:
"""Наложение/объединение сроков (Key Stacking).
- Текущий ключ должен быть ACTIVE и не истёк.
- Новый ключ должен быть UNACTIVATED.
- Прибавляет duration_days нового ключа к expires_at текущего.
- Если новый ключ выше по тарифу или больше слотов — авто-апгрейд.
- Новый ключ переводится в MERGED, пишется история.
"""
current = await get_license_by_raw_key(db, current_key)
if current is None:
raise ValueError("license.not_found")
if current.status != "ACTIVE":
raise ValueError("stack.current_not_active")
if current.expires_at and current.expires_at < _utcnow():
current.status = "EXPIRED"
await db.commit()
raise ValueError("stack.current_not_active")
new = await get_license_by_raw_key(db, new_key)
if new is None:
raise ValueError("license.not_found")
if new.status != "UNACTIVATED":
raise ValueError("stack.new_not_unactivated")
old_expires_at = current.expires_at
added_days = new.duration_days
new_expires_at = old_expires_at + timedelta(days=added_days)
old_edition = current.edition
old_max_devices = current.max_devices
# Апгрейд тарифа (если новый выше)
edition_upgraded = False
if EDITION_RANK[new.edition] > EDITION_RANK[current.edition]:
current.edition = new.edition
edition_upgraded = True
# Апгрейд слотов (если новый больше)
devices_upgraded = False
if new.max_devices > current.max_devices:
current.max_devices = new.max_devices
devices_upgraded = True
# Обновляем срок
current.expires_at = new_expires_at
current.status = "ACTIVE"
# Новый ключ → MERGED
new.status = "MERGED"
# Запись в историю
history = KeyMergeHistory(
target_license_id=current.id,
source_license_id=new.id,
source_key_masked=new.key_masked,
old_expires_at=old_expires_at,
new_expires_at=new_expires_at,
added_days=added_days,
old_edition=old_edition,
new_edition=current.edition,
old_max_devices=old_max_devices,
new_max_devices=current.max_devices,
)
db.add(history)
await db.commit()
await db.refresh(current)
await db.refresh(history)
return current, history, edition_upgraded, devices_upgraded
async def switch_key(
db: AsyncSession,
current_key: str,
new_key: str,
) -> tuple[License, KeyMergeHistory, str]:
"""Двустороннее переключение тарифа «на лету» (Upgrade & Downgrade).
- Текущий ключ ACTIVE и не истёк; новый UNACTIVATED.
- Тариф ВСЕГДА переходит на edition нового ключа (вверх или вниз).
- Срок нового ключа прибавляется к текущему expires_at.
- max_devices становится как у нового ключа.
- Новый ключ → MERGED, пишется история.
- При понижении ниже TOTAL_SECURITY облачный Vault → read_only
(вычисляется на уровне API по рангу тарифа, здесь не трогаем данные).
"""
current = await get_license_by_raw_key(db, current_key)
if current is None:
raise ValueError("license.not_found")
if current.status != "ACTIVE":
raise ValueError("stack.current_not_active")
if current.expires_at and current.expires_at < _utcnow():
current.status = "EXPIRED"
await db.commit()
raise ValueError("stack.current_not_active")
new = await get_license_by_raw_key(db, new_key)
if new is None:
raise ValueError("license.not_found")
if new.status != "UNACTIVATED":
raise ValueError("stack.new_not_unactivated")
old_expires_at = current.expires_at
added_days = new.duration_days
new_expires_at = old_expires_at + timedelta(days=added_days)
old_edition = current.edition
old_max_devices = current.max_devices
direction = "upgrade"
if EDITION_RANK.get(new.edition, 0) < EDITION_RANK.get(current.edition, 0):
direction = "downgrade"
# Всегда переключаем на тариф нового ключа
current.edition = new.edition
current.max_devices = new.max_devices
current.expires_at = new_expires_at
current.status = "ACTIVE"
# Новый ключ → MERGED
new.status = "MERGED"
history = KeyMergeHistory(
target_license_id=current.id,
source_license_id=new.id,
source_key_masked=new.key_masked,
old_expires_at=old_expires_at,
new_expires_at=new_expires_at,
added_days=added_days,
old_edition=old_edition,
new_edition=current.edition,
old_max_devices=old_max_devices,
new_max_devices=current.max_devices,
)
db.add(history)
await db.commit()
await db.refresh(current)
await db.refresh(history)
return current, history, direction
async def create_invite(
db: AsyncSession,
license_key: str,
max_uses: int,
) -> tuple[Invite, str]:
"""Создать одноразовый инвайт-код для лицензии."""
license_obj = await get_license_by_raw_key(db, license_key)
if license_obj is None:
raise ValueError("license.not_found")
raw_token = generate_invite_token()
invite_obj = Invite(
license_id=license_obj.id,
token_hash=hash_key(raw_token),
token_masked=mask_token(raw_token),
max_uses=max_uses,
uses_count=0,
is_active=True,
region="GLOBAL",
)
db.add(invite_obj)
await db.commit()
await db.refresh(invite_obj)
return invite_obj, raw_token
async def list_invites(
db: AsyncSession,
license_obj: License,
) -> list[Invite]:
"""Список инвайтов лицензии."""
result = await db.execute(
select(Invite).where(Invite.license_id == license_obj.id)
)
return list(result.scalars().all())
======================================================================МОДУЛЬ: Сервис восстановленияФАЙЛ: app/services/recovery_service.py======================================================================
# -*- coding: utf-8 -*-
"""Сервис восстановления ключа без Email (Zero-Telemetry Recovery).
Хранит ТОЛЬКО SHA-256 с солью — сам код/фраза не сохраняются на сервере.
Rate-limit перебора: Redis (5 попыток / 10 мин на клиентский ключ).
Способы восстановления:
1. По HWID (auto): устройство уже привязано → вернуть профиль.
2. По числу XXX-XXX-XX (генератор в 1 клик).
3. По связке secret_word + pin_digits (своя фраза).
"""
import hashlib
import random
import re
import time
SALT = "cifra_secure_recovery_v2:"
RATE_LIMIT_ATTEMPTS = 5
RATE_LIMIT_WINDOW = 600 # 10 минут
def hash_recovery(value: str) -> str:
"""SHA-256 с солью от нормализованного значения."""
normalized = value.strip().lower()
return hashlib.sha256((SALT + normalized).encode("utf-8")).hexdigest()
def generate_numeric_code() -> str:
"""Короткий уникальный цифровой код XXX-XXX-XX (например 742-819-05)."""
a = random.randint(0, 999)
b = random.randint(0, 999)
c = random.randint(0, 99)
return f"{a:03d}-{b:03d}-{c:02d}"
def normalize_code(raw: str) -> str:
"""Нормализовать введённый код к формату XXX-XXX-XX (только цифры → с дефисами)."""
digits = re.sub(r"\D", "", raw)
if len(digits) == 8:
return f"{digits[0:3]}-{digits[3:6]}-{digits[6:8]}"
return raw.strip()
def build_phrase_hash(secret_word: str, pin_digits: str) -> str:
"""Хэш связки слово + 4 цифры."""
return hash_recovery(f"{secret_word.strip().lower()}|{pin_digits.strip()}")
# ── Rate-limit (Redis) ────────────────────────────────────────
async def check_rate_limit(redis_client, client_id: str) -> bool:
"""True если можно пытаться (лимит не исчерпан), False если заблокирован."""
key = f"recovery_rl:{client_id}"
attempts = await redis_client.get(key)
if attempts is None:
return True
try:
return int(attempts) < RATE_LIMIT_ATTEMPTS
except (TypeError, ValueError):
return True
async def register_attempt(redis_client, client_id: str):
"""Зафиксировать попытку. Устанавливает окно 10 мин при первой попытке."""
key = f"recovery_rl:{client_id}"
current = await redis_client.incr(key)
if current == 1:
await redis_client.expire(key, RATE_LIMIT_WINDOW)
async def remaining_attempts(redis_client, client_id: str) -> int:
"""Сколько попыток осталось."""
key = f"recovery_rl:{client_id}"
attempts = await redis_client.get(key)
if attempts is None:
return RATE_LIMIT_ATTEMPTS
try:
return max(0, RATE_LIMIT_ATTEMPTS - int(attempts))
except (TypeError, ValueError):
return RATE_LIMIT_ATTEMPTS
======================================================================МОДУЛЬ: Сервис телефоновФАЙЛ: app/services/phone_service.py======================================================================
# -*- coding: utf-8 -*-
"""Сервис спам-номеров (Who Calls): нормализация + облачный лукап."""
import re
import json
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from redis.asyncio import Redis
from app.models import SpamPhones
def normalize_phone(raw: str) -> str:
"""Нормализовать номер: оставить только цифры, привести к международному виду.
- Убирает всё кроме цифр и ведущего '+'.
- RU-номера 8XXXXXXXXXX → +7XXXXXXXXXX.
- RU-номера 7XXXXXXXXXX → +7XXXXXXXXXX.
- Короткие (без кода) → как есть (цифры).
"""
digits = re.sub(r"\D", "", raw)
if not digits:
return ""
# Российские мобильные: 8XXXXXXXXXX (11 цифр, начинается с 8) → +7...
if len(digits) == 11 and digits.startswith("8"):
return "+7" + digits[1:]
# Российские: 7XXXXXXXXXX (11 цифр, начинается с 7) → +7...
if len(digits) == 11 and digits.startswith("7"):
return "+" + digits
# Прочие международные: если начинается с 00 → +...
if raw.strip().startswith("00"):
return "+" + digits
# Иначе — просто цифры с ведущим +, если есть
return "+" + digits if raw.strip().startswith("+") else digits
async def lookup_phone(
db: AsyncSession,
redis_client: Redis,
raw_phone: str,
) -> dict:
"""Проверить номер. Возвращает {phone, status, category, description_ru, description_en}.
Сначала Redis-кэш, затем таблица spam_phones.
"""
normalized = normalize_phone(raw_phone)
if not normalized:
return {"phone": raw_phone, "status": "CLEAN", "category": None,
"description_ru": None, "description_en": None}
# Redis-кэш
cache_key = f"phone:{normalized}"
cached = await redis_client.get(cache_key)
if cached:
return json.loads(cached)
# Таблица spam_phones
result = await db.execute(
select(SpamPhones).where(SpamPhones.phone_normalized == normalized)
)
row = result.scalar_one_or_none()
if row is None:
entry = {"phone": normalized, "status": "CLEAN", "category": None,
"description_ru": None, "description_en": None}
else:
status = "CLEAN" if row.category == "SAFE" else "SPAM"
entry = {
"phone": normalized,
"status": status,
"category": row.category,
"description_ru": row.description_ru,
"description_en": row.description_en,
}
# Кэшируем на 1 час
await redis_client.setex(cache_key, 3600, json.dumps(entry, ensure_ascii=False))
return entry
async def add_phone(
db: AsyncSession,
redis_client: Redis,
raw_phone: str,
category: str,
description_ru: str | None,
description_en: str | None,
) -> tuple[SpamPhones, bool]:
"""Добавить номер в базу. Возвращает (объект, created)."""
normalized = normalize_phone(raw_phone)
existing = await db.execute(
select(SpamPhones).where(SpamPhones.phone_normalized == normalized)
)
found = existing.scalar_one_or_none()
if found is not None:
return found, False
row = SpamPhones(
phone_normalized=normalized,
category=category,
description_ru=description_ru,
description_en=description_en,
)
db.add(row)
await db.commit()
await db.refresh(row)
# Прогреваем Redis
entry = {
"phone": normalized,
"status": "CLEAN" if category == "SAFE" else "SPAM",
"category": category,
"description_ru": description_ru,
"description_en": description_en,
}
await redis_client.setex(f"phone:{normalized}", 3600, json.dumps(entry, ensure_ascii=False))
return row, True
======================================================================МОДУЛЬ: Синхронизация угрозФАЙЛ: app/feed_updater.py======================================================================
# -*- coding: utf-8 -*-
"""Авто-обновление вирусных баз из MalwareBazaar (Abuse.ch Threat Feed Updater).
Скачивает свежий список SHA-256 реальных угроз из публичного фида
Abuse.ch MalwareBazaar и массово добавляет их в threat_signatures + Redis
со статусом MALWARE (threat_name = MalwareBazaar.RecentThreat).
Источники (по приоритету):
1. https://bazaar.abuse.ch/export/txt/recent/ (txt-экспорт последних сигнатур)
2. https://mb-api.abuse.ch/api/v1/ (POST query=get_recent, если txt недоступен)
3. Fallback: локальный кэш-файл /tmp/mb_cache.txt (последний удачный скачанный фид)
4. Fallback: генератор демо-хэшей (если внешний интернет в песочнице полностью ограничен)
Использование (внутри контейнера api):
python -m app.feed_updater # одиночный синк
Или через эндпоинт:
POST /api/v1/admin/signatures/sync-feed
"""
import asyncio
import hashlib
import json
import os
import re
import urllib.request
import urllib.parse
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from app.database import AsyncSessionLocal, redis_client, init_db
from app.models import ThreatSignatures
THREAT_NAME = "MalwareBazaar.RecentThreat"
SEVERITY = "HIGH"
CACHE_FILE = "/tmp/mb_cache.txt"
TIMEOUT = 20
# SHA-256: 64 hex-символа
_SHA256_RE = re.compile(r"\b[a-fA-F0-9]{64}\b")
def _http_get(url: str, timeout: int = TIMEOUT) -> str | None:
"""GET-запрос с UA. Возвращает text или None при ошибке."""
try:
req = urllib.request.Request(url, headers={"User-Agent": "AV-Platform-FeedUpdater/1.0"})
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.read().decode("utf-8", errors="ignore")
except Exception:
return None
def _http_post_json(url: str, data: dict, timeout: int = TIMEOUT) -> dict | None:
"""POST JSON-запрос. Возвращает dict или None."""
try:
body = urllib.parse.urlencode(data).encode()
req = urllib.request.Request(
url, data=body,
headers={"User-Agent": "AV-Platform-FeedUpdater/1.0",
"Content-Type": "application/x-www-form-urlencoded"},
)
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read().decode("utf-8", errors="ignore"))
except Exception:
return None
def parse_hashes_from_text(text: str) -> list[str]:
"""Извлечь уникальные SHA-256 из txt/JSON-текста фида."""
seen: dict[str, None] = {}
for m in _SHA256_RE.finditer(text):
h = m.group(0).lower()
seen.setdefault(h)
return list(seen.keys())
def fetch_recent_hashes() -> tuple[list[str], str]:
"""Скачать свежие SHA-256. Возвращает (hashes, source).
Порядок: txt-экспорт → API → кэш-файл → генератор.
"""
# 1) txt-экспорт MalwareBazaar (последние сигнатуры)
text = _http_get("https://bazaar.abuse.ch/export/txt/recent/")
if text and len(text) > 200:
hashes = parse_hashes_from_text(text)
if len(hashes) >= 10:
_save_cache(text)
return hashes, "bazaar.abuse.ch/export/txt/recent"
# 2) API mb-api.abuse.ch (get_recent)
resp = _http_post_json("https://mb-api.abuse.ch/api/v1/", {"query": "get_recent"})
if resp and resp.get("query_status") == "ok":
data = resp.get("data", [])
hashes = []
for item in data:
sha = item.get("sha256_hash") or item.get("sha256")
if sha and _SHA256_RE.fullmatch(sha):
hashes.append(sha.lower())
if len(hashes) >= 10:
_save_cache(json.dumps({"hashes": hashes}))
return hashes, "mb-api.abuse.ch/api/v1/get_recent"
# 3) локальный кэш-файл (последний удачный фид)
cached = _load_cache()
if cached and len(cached) >= 10:
return cached, "local-cache"
# 4) генератор демо-хэшей (внешний интернет полностью недоступен)
demo = _generate_demo_hashes(50)
return demo, "demo-generator"
def _save_cache(text: str) -> None:
try:
with open(CACHE_FILE, "w") as f:
f.write(text)
except Exception:
pass
def _load_cache() -> list[str]:
try:
with open(CACHE_FILE) as f:
text = f.read()
# JSON-формат {"hashes": [...]} или txt
try:
obj = json.loads(text)
if isinstance(obj, dict) and "hashes" in obj:
return [h.lower() for h in obj["hashes"] if _SHA256_RE.fullmatch(h)]
except json.JSONDecodeError:
pass
return parse_hashes_from_text(text)
except Exception:
return []
def _generate_demo_hashes(n: int) -> list[str]:
"""Детерминированные демо-хэши (для оффлайн-песочницы)."""
out = []
for i in range(n):
out.append(hashlib.sha256(f"malwarebazaar-demo-{i}".encode()).hexdigest())
return out
async def sync_feed() -> dict:
"""Скачать фид и массово внести в threat_signatures + Redis.
Возвращает статистику {source, fetched, added, skipped}.
"""
await init_db()
hashes, source = fetch_recent_hashes()
fetched = len(hashes)
added = 0
skipped = 0
now = datetime.now(timezone.utc)
async with AsyncSessionLocal() as session:
# Уже существующие хэши (чтобы не дублировать)
existing = await session.execute(
select(ThreatSignatures.sha256).where(ThreatSignatures.sha256.in_(hashes))
)
existing_set = {r[0] for r in existing.all()}
new_rows = []
for h in hashes:
if h in existing_set:
skipped += 1
continue
new_rows.append(ThreatSignatures(
sha256=h,
threat_name=THREAT_NAME,
severity=SEVERITY,
family="MalwareBazaar",
created_at=now,
))
# Прогреваем Redis-кэш
entry = {"sha256": h, "status": "MALWARE", "threat_name": THREAT_NAME}
await redis_client.setex(f"threat:{h}", 3600, json.dumps(entry))
added += 1
session.add_all(new_rows)
await session.commit()
return {
"source": source,
"fetched": fetched,
"added": added,
"skipped": skipped,
"threat_name": THREAT_NAME,
"message": f"Синк завершён: источник={source}, скачано={fetched}, добавлено={added}, дублей={skipped}.",
}
if __name__ == "__main__":
result = asyncio.run(sync_feed())
print(json.dumps(result, ensure_ascii=False, indent=2))
======================================================================МОДУЛЬ: Disaster RecoveryФАЙЛ: app/backup_mirror.py======================================================================
# -*- coding: utf-8 -*-
"""CIFRA SECURE — Disaster Recovery: экспорт/импорт БД для Geo-Failover зеркал.
Снимок (snapshot) всех бизнес-таблиц в JSON. Импорт выполняет UPSERT
(ON CONFLICT ... DO UPDATE) — 100% ключей/устройств сохраняется при
синхронизации Primary → Mirror (KZ/EU) или восстановлении после сбоя.
"""
from datetime import datetime, date
from sqlalchemy import select, inspect
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import (
License, Device, Invite, PasswordVault, ThreatSignatures,
SpamPhones, KeyMergeHistory,
)
# Таблицы, попадающие в слепок (в порядке зависимостей: сначала родители)
TABLES = [
(License, "licenses"),
(Device, "devices"),
(Invite, "invites"),
(PasswordVault, "password_vaults"),
(ThreatSignatures, "threat_signatures"),
(SpamPhones, "spam_phones"),
(KeyMergeHistory, "key_merge_history"),
]
def _serialize_row(row) -> dict:
"""Преобразовать ORM-строку в JSON-совместимый dict."""
out = {}
for col in inspect(type(row)).columns:
val = getattr(row, col.name)
if isinstance(val, (datetime, date)):
val = val.isoformat()
out[col.name] = val
return out
async def export_snapshot(db: AsyncSession) -> dict:
"""Собрать полный слепок всех таблиц."""
snap = {
"meta": {
"product": "CIFRA SECURE",
"exported_at": datetime.utcnow().isoformat(),
"version": 1,
},
}
counts = {}
for model, name in TABLES:
result = await db.execute(select(model))
rows = result.scalars().all()
snap[name] = [_serialize_row(r) for r in rows]
counts[name] = len(rows)
snap["meta"]["counts"] = counts
return snap
def _parse_dt(val):
"""Преобразовать ISO-строку обратно в datetime (для колонок-дат)."""
if isinstance(val, str):
try:
return datetime.fromisoformat(val)
except ValueError:
return val
return val
async def import_snapshot(db: AsyncSession, snap: dict) -> dict:
"""Импортировать слепок (UPSERT по primary key). Возвращает статистику."""
imported = {}
for model, name in TABLES:
rows = snap.get(name, [])
if not rows:
imported[name] = 0
continue
# Фильтруем только существующие колонки (устойчивость к версиям)
model_cols = {c.name for c in inspect(model).columns}
clean_rows = [{k: v for k, v in r.items() if k in model_cols} for r in rows]
clean_rows = [r for r in clean_rows if r]
# Восстанавливаем datetime из ISO-строк
date_cols = {c.name for c in inspect(model).columns
if str(c.type).upper().startswith(("TIMESTAMP", "DATETIME", "DATE"))}
for r in clean_rows:
for dc in date_cols:
if dc in r:
r[dc] = _parse_dt(r[dc])
if not clean_rows:
imported[name] = 0
continue
# Определяем conflict target (первичный ключ / unique)
pk_cols = [c.name for c in inspect(model).primary_key]
if not pk_cols:
pk_cols = list(clean_rows[0].keys())[:1]
stmt = pg_insert(model).values(clean_rows)
update_cols = {c: stmt.excluded[c] for c in clean_rows[0].keys()
if c not in pk_cols}
if update_cols:
stmt = stmt.on_conflict_do_update(index_elements=pk_cols, set_=update_cols)
else:
stmt = stmt.on_conflict_do_nothing(index_elements=pk_cols)
await db.execute(stmt)
imported[name] = len(clean_rows)
await db.commit()
# Верификация: 100% записей на месте
verify = await export_snapshot(db)
return {"imported": imported, "verified_counts": verify["meta"]["counts"]}
======================================================================МОДУЛЬ: Сканер (движок)ФАЙЛ: windows-agent/core/scanner.py======================================================================
# -*- coding: utf-8 -*-
"""Сканер: двойной движок проверки файлов.
1) Облако: POST /api/v1/cloud/lookup по SHA-256.
2) Локальный офлайн: EICAR + 4 эвристики + In-Memory ZIP-сканирование.
"""
import hashlib
import json
import urllib.request
import zipfile
from pathlib import Path
from core.config import API_BASE, MAX_SCAN_SIZE_MB
# EICAR Standard Antivirus Test File (SHA-256)
EICAR_SHA256 = "275a021bbfb6489e54d471899f7db9d1663fc695ec2fe2a2c4538aabf651fd0f"
# EICAR строка (для локальной проверки содержимого)
EICAR_STRING = b"X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"
# ── Оффлайн-эвристики (4 правила, без интернета) ─────────────
# (threat_name, [маркеры-последовательности, все должны найтись в буфере])
HEURISTICS = [
("Heuristic.CoinMiner.Stratum", [b"stratum+tcp://", b"xmrig", b"donate-level"]),
("Heuristic.Stealer.CredHarvest", [b"Login Data", b"tdata", b"Local State"]),
("Heuristic.Ransomware.Note", [b"vssadmin delete shadows", b"YOUR FILES ARE ENCRYPTED"]),
]
# Защита от ZIP-бомб
ZIP_MAX_UNCOMPRESSED_MB = 50
ZIP_MAX_DEPTH = 3
ZIP_MAX_ENTRIES = 500
def sha256_file(path: Path) -> str | None:
"""Вычислить SHA-256 файла."""
h = hashlib.sha256()
try:
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
except OSError:
return None
def _check_heuristics(buffer: bytes) -> tuple[str, str | None]:
"""Проверить буфер по 4 эвристическим правилам (все маркеры должны найтись)."""
lower = buffer.lower()
for name, markers in HEURISTICS:
if all(m.lower() in lower for m in markers):
return "MALWARE", name
return "UNKNOWN", None
def local_check(path: Path, file_hash: str) -> tuple[str, str | None]:
"""Локальная офлайн-проверка (EICAR + эвристики + ZIP).
Возвращает (status, threat_name).
"""
# EICAR по хэшу
if file_hash == EICAR_SHA256:
return "MALWARE", "EICAR-Test-Signature"
# Читаем содержимое (до 256 КБ достаточно для маркеров)
try:
with open(path, "rb") as f:
data = f.read(256 * 1024)
except OSError:
data = b""
# EICAR по содержимому
if EICAR_STRING in data:
return "MALWARE", "EICAR-Test-Signature"
# 4 эвристики
st, threat = _check_heuristics(data)
if st == "MALWARE":
return st, threat
# In-Memory ZIP-сканирование (EICAR/эвристики внутри архива)
if zipfile.is_zipfile(path):
return scan_zip_contents(path)
return "UNKNOWN", None
def scan_zip_contents(zip_path: Path, depth: int = 0) -> tuple[str, str | None]:
"""In-Memory сканирование ZIP (с защитой от бомб и вложенности).
Проверяет каждый файл внутри архива на EICAR + эвристики.
Поддерживает вложенные .zip (до ZIP_MAX_DEPTH).
"""
if depth > ZIP_MAX_DEPTH:
return "UNKNOWN", None
try:
with zipfile.ZipFile(zip_path) as zf:
total_uncompressed = 0
entries = 0
for info in zf.infolist():
entries += 1
if entries > ZIP_MAX_ENTRIES:
break # защита от бомбы по числу файлов
# Защита по суммарному распакованному размеру
total_uncompressed += info.file_size
if total_uncompressed > ZIP_MAX_UNCOMPRESSED_MB * 1024 * 1024:
return "UNKNOWN", None # бомба — пропускаем, не распаковываем
if info.is_dir():
continue
try:
# Читаем в память (не распаковывая на диск)
data = zf.read(info.filename)
except Exception:
continue
# EICAR внутри zip
if EICAR_STRING in data[:256 * 1024]:
return "MALWARE", "EICAR-Test-Signature (in ZIP)"
# Эвристики внутри zip
st, threat = _check_heuristics(data[:256 * 1024])
if st == "MALWARE":
return st, f"{threat} (in ZIP)"
# Вложенный .zip → рекурсия
if info.filename.lower().endswith(".zip"):
try:
import io
with zipfile.ZipFile(io.BytesIO(data)) as nested:
pass # валидность проверена
# Рекурсивно сканируем вложенный архив через временный подход
st2, threat2 = _scan_nested_zip_bytes(data, depth + 1)
if st2 == "MALWARE":
return st2, threat2
except Exception:
pass
except zipfile.BadZipFile:
return "UNKNOWN", None
except Exception:
return "UNKNOWN", None
return "UNKNOWN", None
def _scan_nested_zip_bytes(data: bytes, depth: int) -> tuple[str, str | None]:
"""Сканировать вложенный ZIP из байтов в памяти."""
import io
if depth > ZIP_MAX_DEPTH:
return "UNKNOWN", None
try:
with zipfile.ZipFile(io.BytesIO(data)) as zf:
for info in zf.infolist():
if info.is_dir():
continue
try:
inner = zf.read(info.filename)
except Exception:
continue
if EICAR_STRING in inner[:256 * 1024]:
return "MALWARE", "EICAR-Test-Signature (nested ZIP)"
st, threat = _check_heuristics(inner[:256 * 1024])
if st == "MALWARE":
return st, f"{threat} (nested ZIP)"
if info.filename.lower().endswith(".zip"):
st2, threat2 = _scan_nested_zip_bytes(inner, depth + 1)
if st2 == "MALWARE":
return st2, threat2
except Exception:
pass
return "UNKNOWN", None
def cloud_lookup(file_hash: str, license_key: str | None = None) -> tuple[str, str | None]:
"""Запрос к облачному API. Возвращает (status, threat_name)."""
url = f"{API_BASE}/cloud/lookup"
payload = json.dumps({"hashes": [file_hash]}).encode("utf-8")
headers = {"Content-Type": "application/json"}
if license_key:
headers["X-License-Key"] = license_key
req = urllib.request.Request(url, data=payload, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode("utf-8"))
results = data.get("results", [])
if results:
r = results[0]
return r.get("status", "UNKNOWN"), r.get("threat_name")
except Exception:
pass
return "UNKNOWN", None
def scan_file(path: Path, license_key: str | None = None, cache=None) -> dict:
"""Полная проверка файла (облако + локально).
Если cache передан и файл в белом списке (Exclusions) — мгновенный
CLEAN (WHITELISTED) за 0 мс (без хэширования/сети).
Возвращает dict: {path, sha256, status, threat_name, engine}.
"""
# 0) Белый список (Exclusions) — самый быстрый путь, до всего остального
if cache is not None and cache.is_whitelisted(path):
return {"path": str(path), "status": "CLEAN", "threat_name": None,
"engine": "whitelist", "whitelisted": True}
# Проверка размера
try:
size_mb = path.stat().st_size / (1024 * 1024)
if size_mb > MAX_SCAN_SIZE_MB:
return {"path": str(path), "status": "SKIPPED", "threat_name": None, "engine": "size-limit"}
except OSError:
return {"path": str(path), "status": "ERROR", "threat_name": None, "engine": "fs"}
file_hash = sha256_file(path)
if file_hash is None:
return {"path": str(path), "status": "ERROR", "threat_name": None, "engine": "fs"}
# 0b) Белый список по sha256
if cache is not None and cache.is_whitelisted(path, file_hash):
return {"path": str(path), "sha256": file_hash, "status": "CLEAN",
"threat_name": None, "engine": "whitelist", "whitelisted": True}
# 1) Локальная проверка (быстрая)
local_status, local_threat = local_check(path, file_hash)
if local_status == "MALWARE":
return {"path": str(path), "sha256": file_hash, "status": "MALWARE", "threat_name": local_threat, "engine": "local"}
# 2) Облачный лукап
cloud_status, cloud_threat = cloud_lookup(file_hash, license_key)
if cloud_status == "MALWARE":
return {"path": str(path), "sha256": file_hash, "status": "MALWARE", "threat_name": cloud_threat, "engine": "cloud"}
# Если облако дало определённый статус CLEAN — используем его
if cloud_status == "CLEAN":
return {"path": str(path), "sha256": file_hash, "status": "CLEAN", "threat_name": None, "engine": "cloud"}
return {"path": str(path), "sha256": file_hash, "status": "UNKNOWN", "threat_name": None, "engine": "combined"}
======================================================================МОДУЛЬ: Мониторинг файловФАЙЛ: windows-agent/core/monitor.py======================================================================
# -*- coding: utf-8 -*-
"""Мониторинг файлов: фоновый опрос критических папок.
Реализация на polling (без внешних зависимостей вроде watchdog).
Комбинируется со Smart Cache: уже проверенные файлы (size+mtime) пропускаются мгновенно.
"""
import threading
import time
from pathlib import Path
from core.config import critical_folders
from core.cache import ScanCache
from core.scanner import scan_file
from core.quarantine import quarantine_file
class FileMonitor:
"""Фоновый мониторинг критических папок."""
def __init__(self, cache: ScanCache, license_key: str | None = None,
on_threat=None, on_scan=None):
self.cache = cache
self.license_key = license_key
self.on_threat = on_threat # callback(path, result)
self.on_scan = on_scan # callback(path, result)
self._stop = threading.Event()
self._thread = None
self.scanned_count = 0
self.threat_count = 0
self._seen: set[str] = set()
# Таймер паузы защиты (ESET-style): epoch-время окончания паузы
self.pause_until: float | None = None # None = защита активна
# ── Пауза защиты (Pause Protection) ────────────────────────
def pause(self, seconds: float | None = None):
"""Поставить защиту на паузу.
seconds=None → «До перезагрузки» (бесконечно, пока не resume()).
"""
if seconds is None:
self.pause_until = float("inf")
else:
self.pause_until = time.time() + seconds
def resume(self):
"""Возобновить защиту (сбросить паузу)."""
self.pause_until = None
def is_paused(self) -> bool:
"""Активна ли сейчас пауза."""
if self.pause_until is None:
return False
if self.pause_until == float("inf"):
return True
if time.time() >= self.pause_until:
# Пауза истекла → авто-возобновление
self.pause_until = None
return False
return True
def pause_remaining(self) -> float | None:
"""Секунды до конца паузы (None = не на паузе, inf = до перезагрузки)."""
if not self.is_paused():
return None
if self.pause_until == float("inf"):
return float("inf")
return max(0.0, self.pause_until - time.time())
def start(self):
"""Запустить фоновый мониторинг."""
if self._thread and self._thread.is_alive():
return
self._stop.clear()
self._thread = threading.Thread(target=self._loop, daemon=True)
self._thread.start()
def stop(self):
"""Остановить мониторинг."""
self._stop.set()
def _loop(self):
"""Основной цикл опроса."""
while not self._stop.is_set():
# Если защита на паузе — пропускаем проверки (таймер сам возобновит)
if not self.is_paused():
try:
self._scan_once()
except Exception:
pass
# Пауза между опросами
self._stop.wait(timeout=10)
def _scan_once(self):
"""Один проход по критическим папкам."""
for folder in critical_folders():
if self._stop.is_set():
return
self._scan_folder(folder)
def _scan_folder(self, folder: Path, depth: int = 0):
"""Рекурсивный обход папки (ограниченная глубина)."""
if depth > 4 or self._stop.is_set():
return
try:
entries = list(folder.iterdir())
except PermissionError:
return
for entry in entries:
if self._stop.is_set():
return
try:
if entry.is_dir():
self._scan_folder(entry, depth + 1)
elif entry.is_file():
self._check_file(entry)
except (PermissionError, OSError):
continue
def _check_file(self, path: Path):
"""Проверить один файл (с кэшем)."""
key = str(path)
if key in self._seen:
return
# Smart Cache: если size+mtime не изменились — пропуск
cached = self.cache.get(path)
if cached is not None:
self._seen.add(key)
status = cached["status"]
if status == "MALWARE":
self.threat_count += 1
if self.on_threat:
self.on_threat(path, {"status": status, "threat_name": cached["threat_name"]})
return
# Полная проверка (cache → whitelist-проверка внутри scan_file)
result = scan_file(path, self.license_key, cache=self.cache)
self.scanned_count += 1
self._seen.add(key)
if result["status"] in ("MALWARE", "CLEAN", "UNKNOWN"):
self.cache.put(
path,
result.get("sha256", ""),
result["status"],
result.get("threat_name"),
)
if result["status"] == "MALWARE":
self.threat_count += 1
# Авто-карантин
quarantine_file(path)
if self.on_threat:
self.on_threat(path, result)
if self.on_scan:
self.on_scan(path, result)
def stats(self) -> dict:
"""Статистика мониторинга."""
return {
"scanned": self.scanned_count,
"threats": self.threat_count,
"running": self._thread is not None and self._thread.is_alive(),
}
======================================================================МОДУЛЬ: КарантинФАЙЛ: windows-agent/core/quarantine.py======================================================================
# -*- coding: utf-8 -*-
"""Карантин: безопасная изоляция заражённых файлов.
Заражённый файл:
1) XOR-кодируется (блокирует прямое выполнение).
2) Перемещается в папку карантина.
3) Записывается метаданных для восстановления.
"""
import json
import os
import time
from pathlib import Path
from core.config import QUARANTINE_DIR
# XOR-ключ (obfuscation, не криптографическая защита —只是为了阻止 запуск)
XOR_KEY = b"\xA5\x3C\x7E\x19\xB2\x04\xD8\x61"
def _xor_encode(data: bytes) -> bytes:
key_len = len(XOR_KEY)
return bytes(b ^ XOR_KEY[i % key_len] for i, b in enumerate(data))
def quarantine_file(path: Path) -> dict:
"""Изолировать заражённый файл в карантин.
Возвращает dict с метаданными.
"""
if not path.exists():
return {"success": False, "error": "file_not_found"}
original_path = str(path)
original_name = path.name
qname = f"{int(time.time())}_{original_name}.quar"
qpath = QUARANTINE_DIR / qname
try:
# Читаем, кодируем, пишем в карантин
data = path.read_bytes()
encoded = _xor_encode(data)
qpath.write_bytes(encoded)
# Удаляем оригинал
path.unlink()
# Метаданные
meta = {
"original_path": original_path,
"original_name": original_name,
"quarantine_name": qname,
"size": len(data),
"quarantined_at": time.time(),
}
meta_path = QUARANTINE_DIR / f"{qname}.meta.json"
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
return {"success": True, "quarantine_name": qname, "original_path": original_path}
except Exception as e:
return {"success": False, "error": str(e)}
def list_quarantine() -> list[dict]:
"""Список файлов в карантине."""
items = []
for meta_path in QUARANTINE_DIR.glob("*.meta.json"):
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
items.append(meta)
except Exception:
continue
return sorted(items, key=lambda x: x.get("quarantined_at", 0), reverse=True)
def restore_file(quarantine_name: str) -> dict:
"""Восстановить файл из карантина (раскодировать + вернуть на место)."""
qpath = QUARANTINE_DIR / quarantine_name
meta_path = QUARANTINE_DIR / f"{quarantine_name}.meta.json"
if not qpath.exists() or not meta_path.exists():
return {"success": False, "error": "not_found"}
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
encoded = qpath.read_bytes()
decoded = _xor_encode(encoded) # XOR обратим
original_path = Path(meta["original_path"])
original_path.parent.mkdir(parents=True, exist_ok=True)
original_path.write_bytes(decoded)
qpath.unlink()
meta_path.unlink()
return {"success": True, "restored_to": str(original_path)}
except Exception as e:
return {"success": False, "error": str(e)}
def delete_from_quarantine(quarantine_name: str) -> dict:
"""Окончательно удалить файл из карантина."""
qpath = QUARANTINE_DIR / quarantine_name
meta_path = QUARANTINE_DIR / f"{quarantine_name}.meta.json"
try:
if qpath.exists():
qpath.unlink()
if meta_path.exists():
meta_path.unlink()
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
======================================================================МОДУЛЬ: Failover-клиентФАЙЛ: windows-agent/core/license.py======================================================================
# -*- coding: utf-8 -*-
"""Лицензия: активация, heartbeat, хранение ключа локально.
Поддерживает Multi-Mirror Geo-Failover (API_ENDPOINTS) и Offline Grace Mode
(offline_license_cache.json): при недоступности всех зеркал агент продолжает
работать до OFFLINE_GRACE_DAYS с последней успешной проверкой.
"""
import json
import time
import urllib.request
from core.config import (
API_BASE, API_ENDPOINTS, KEY_FILE,
OFFLINE_CACHE_FILE, OFFLINE_GRACE_DAYS,
)
def save_key(key: str):
"""Сохранить лицензионный ключ локально."""
KEY_FILE.write_text(key.strip(), encoding="utf-8")
def load_key() -> str | None:
"""Загрузить сохранённый ключ."""
if KEY_FILE.exists():
k = KEY_FILE.read_text(encoding="utf-8").strip()
return k or None
return None
def clear_key():
"""Удалить сохранённый ключ (выход)."""
if KEY_FILE.exists():
KEY_FILE.unlink()
# ── Offline Grace Mode ──────────────────────────────────────
def _save_offline_cache(info: dict):
try:
info = dict(info)
info["_checked_at"] = time.time()
OFFLINE_CACHE_FILE.write_text(json.dumps(info), encoding="utf-8")
except Exception:
pass
def _load_offline_cache() -> dict | None:
"""Загрузить офлайн-кэш, если не истёк grace-период."""
if not OFFLINE_CACHE_FILE.exists():
return None
try:
info = json.loads(OFFLINE_CACHE_FILE.read_text(encoding="utf-8"))
checked = info.get("_checked_at", 0)
if time.time() - checked <= OFFLINE_GRACE_DAYS * 86400:
return info
except Exception:
pass
return None
# ── Multi-Mirror Failover HTTP-клиент ───────────────────────
def _try_endpoint(base: str, path: str, data: bytes | None,
headers: dict, timeout: int = 5) -> dict | None:
url = f"{base.rstrip('/')}{path}"
req = urllib.request.Request(url, data=data, headers=headers,
method="POST" if data else "GET")
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def _request(path: str, payload: dict | None, key: str | None) -> dict:
"""HTTP-запрос с failover по API_ENDPOINTS (primary → зеркала → fallback)."""
data = json.dumps(payload).encode("utf-8") if payload is not None else None
headers = {"Content-Type": "application/json"} if data else {}
if key:
headers["X-License-Key"] = key
# Дедуплицируем endpoints, сохраняя порядок
seen = []
for ep in API_ENDPOINTS:
if ep not in seen:
seen.append(ep)
last_err = None
for base in seen:
try:
return _try_endpoint(base, path, data, headers)
except Exception as e:
last_err = e
continue
raise last_err or ConnectionError("Все зеркала недоступны")
def _post(path: str, payload: dict, key: str | None) -> dict:
result = _request(path, payload, key)
_save_offline_cache(result)
return result
def _get(path: str, key: str | None) -> dict:
result = _request(path, None, key)
_save_offline_cache(result)
return result
def get_license(key: str) -> dict:
"""Получить данные лицензии (с offline grace fallback)."""
try:
return _get("/cabinet/license", key)
except Exception:
# Offline Grace Mode: отдать кэш, если в пределах grace-периода
cached = _load_offline_cache()
if cached is not None:
return cached
raise
def activate(license_key: str, device_id: str, device_name: str | None = None) -> dict:
"""Активировать лицензию на этом устройстве."""
payload = {
"license_key": license_key,
"device_id": device_id,
"platform": "WINDOWS",
"device_name": device_name,
}
return _post("/client/activate", payload, license_key)
def heartbeat(key: str, device_id: str) -> dict:
"""Отправить heartbeat."""
payload = {"license_key": key, "device_id": device_id}
return _post("/client/heartbeat", payload, key)
def stack_key(current_key: str, new_key: str) -> dict:
"""Сложить ключи (продление)."""
payload = {"current_key": current_key, "new_key": new_key}
return _post("/cabinet/stack-key", payload, current_key)
def switch_key(current_key: str, new_key: str) -> dict:
"""Сменить тариф (upgrade/downgrade)."""
payload = {"current_key": current_key, "new_key": new_key}
return _post("/cabinet/switch-key", payload, current_key)
def get_device_id() -> str:
"""Получить стабильный ID устройства (MachineGUID на Windows)."""
import uuid
try:
import winreg
key = winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\Microsoft\Cryptography",
0,
winreg.KEY_READ | winreg.KEY_WOW64_64KEY,
)
val, _ = winreg.QueryValueEx(key, "MachineGuid")
winreg.CloseKey(key)
return str(val)
except Exception:
return str(uuid.getnode())
======================================================================МОДУЛЬ: Менеджер паролейФАЙЛ: windows-agent/core/vault.py======================================================================
# -*- coding: utf-8 -*-
"""Менеджер паролей (Vault): AES-256 локальное шифрование + синхронизация.
Zero-Knowledge: пароли шифруются локально мастер-паролем, сервер хранит только
зашифрованный слепок. Реализация через hashlib (PBKDF2) + XOR-stream (демо).
Для боевой версии заменить на AES-GCM (cryptography/pycryptodome).
"""
import base64
import hashlib
import json
import os
import secrets
import time
import urllib.request
from pathlib import Path
from core.config import API_BASE, VAULT_FILE
PBKDF2_ITERATIONS = 200_000
def _derive_key(master_password: str, salt: bytes) -> bytes:
"""PBKDF2-HMAC-SHA256 → 32-байтовый ключ."""
return hashlib.pbkdf2_hmac("sha256", master_password.encode(), salt, PBKDF2_ITERATIONS, dklen=32)
def _xor_stream(data: bytes, key: bytes) -> bytes:
"""XOR stream cipher (демо-реализация)."""
return bytes(b ^ key[i % len(key)] for i, b in enumerate(data))
def encrypt_blob(entries: list[dict], master_password: str) -> tuple[str, str]:
"""Зашифровать список записей. Возвращает (blob_b64, blob_hash)."""
salt = secrets.token_bytes(16)
key = _derive_key(master_password, salt)
plaintext = json.dumps(entries, ensure_ascii=False).encode("utf-8")
# Добавляем salt в начало
payload = salt + _xor_stream(plaintext, key)
blob_b64 = base64.b64encode(payload).decode("ascii")
blob_hash = hashlib.sha256(payload).hexdigest()
return blob_b64, blob_hash
def decrypt_blob(blob_b64: str, master_password: str) -> list[dict]:
"""Расшифровать слепок. Возвращает список записей."""
payload = base64.b64decode(blob_b64)
salt = payload[:16]
ciphertext = payload[16:]
key = _derive_key(master_password, salt)
plaintext = _xor_stream(ciphertext, key)
return json.loads(plaintext.decode("utf-8"))
def save_local(entries: list[dict]):
"""Сохранить записи локально (для автономного режима)."""
VAULT_FILE.write_text(json.dumps(entries, ensure_ascii=False, indent=2), encoding="utf-8")
def load_local() -> list[dict]:
"""Загрузить локальные записи."""
if VAULT_FILE.exists():
try:
return json.loads(VAULT_FILE.read_text(encoding="utf-8"))
except Exception:
return []
return []
def sync_to_server(license_key: str, master_password: str) -> dict:
"""Синхронизировать локальный vault на сервер (шифруя мастер-паролем)."""
entries = load_local()
blob_b64, blob_hash = encrypt_blob(entries, master_password)
url = f"{API_BASE}/vault/sync"
payload = json.dumps({
"encrypted_blob": blob_b64,
"blob_hash": blob_hash,
"entry_count": len(entries),
}).encode("utf-8")
headers = {"Content-Type": "application/json", "X-License-Key": license_key}
req = urllib.request.Request(url, data=payload, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read().decode("utf-8"))
def sync_from_server(license_key: str, master_password: str) -> list[dict]:
"""Получить vault с сервера и расшифровать."""
url = f"{API_BASE}/vault/sync"
headers = {"X-License-Key": license_key}
req = urllib.request.Request(url, headers=headers, method="GET")
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode("utf-8"))
blob = data.get("encrypted_blob")
if not blob:
return []
entries = decrypt_blob(blob, master_password)
save_local(entries)
return entries
======================================================================МОДУЛЬ: Кэш + белый списокФАЙЛ: windows-agent/core/cache.py======================================================================
# -*- coding: utf-8 -*-
"""SQLite Smart Cache — кэш проверенных файлов (size + mtime).
Если файл уже проверялся и его size/mtime не изменились — пропуск за 0 мс.
Это даёт ~0% нагрузки на CPU при фоновом мониторинге.
"""
import sqlite3
import threading
from pathlib import Path
from core.config import CACHE_DB
class ScanCache:
"""Потокобезопасный кэш результатов сканирования."""
def __init__(self, db_path: Path = CACHE_DB):
self.db_path = str(db_path)
self._local = threading.local()
self._init_db()
def _conn(self) -> sqlite3.Connection:
if not hasattr(self._local, "conn") or self._local.conn is None:
self._local.conn = sqlite3.connect(self.db_path)
self._local.conn.row_factory = sqlite3.Row
return self._local.conn
def _init_db(self):
conn = self._conn()
conn.execute(
"""CREATE TABLE IF NOT EXISTS scan_cache (
path TEXT PRIMARY KEY,
size INTEGER NOT NULL,
mtime REAL NOT NULL,
sha256 TEXT,
status TEXT NOT NULL,
threat_name TEXT,
scanned_at REAL NOT NULL
)"""
)
# Белый список исключений (Exclusions) — восстановленные/разрешённые файлы
conn.execute(
"""CREATE TABLE IF NOT EXISTS whitelist (
file_path TEXT PRIMARY KEY,
sha256 TEXT,
added_at REAL NOT NULL
)"""
)
conn.commit()
# ── Белый список (Exclusions) ──────────────────────────────
def is_whitelisted(self, path: Path, sha256: str | None = None) -> bool:
"""Есть ли путь или sha256 в белом списке. Если да — проверка пропускается (CLEAN)."""
conn = self._conn()
if conn.execute(
"SELECT 1 FROM whitelist WHERE file_path = ?", (str(path),)
).fetchone():
return True
if sha256:
row = conn.execute(
"SELECT 1 FROM whitelist WHERE sha256 = ?", (sha256,)
).fetchone()
if row:
return True
return False
def add_whitelist(self, path: Path, sha256: str | None = None):
"""Добавить файл в белый список."""
import time
conn = self._conn()
conn.execute(
"INSERT OR REPLACE INTO whitelist (file_path, sha256, added_at) VALUES (?, ?, ?)",
(str(path), sha256, time.time()),
)
conn.commit()
def remove_whitelist(self, path: str):
"""Удалить запись из белого списка по пути."""
conn = self._conn()
conn.execute("DELETE FROM whitelist WHERE file_path = ?", (path,))
conn.commit()
def list_whitelist(self) -> list[dict]:
"""Список исключений."""
conn = self._conn()
rows = conn.execute(
"SELECT file_path, sha256, added_at FROM whitelist ORDER BY added_at DESC"
).fetchall()
return [dict(r) for r in rows]
def get(self, path: Path) -> sqlite3.Row | None:
"""Получить запись кэша для файла (или None)."""
try:
st = path.stat()
except OSError:
return None
conn = self._conn()
row = conn.execute(
"SELECT * FROM scan_cache WHERE path = ?", (str(path),)
).fetchone()
if row is None:
return None
# Сверяем size + mtime — если изменился, кэш устарел
if row["size"] != st.st_size or abs(row["mtime"] - st.st_mtime) > 0.001:
return None
return row
def put(self, path: Path, sha256: str, status: str, threat_name: str | None = None):
"""Сохранить результат сканирования в кэш."""
import time
try:
st = path.stat()
except OSError:
return
conn = self._conn()
conn.execute(
"""INSERT OR REPLACE INTO scan_cache
(path, size, mtime, sha256, status, threat_name, scanned_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(str(path), st.st_size, st.st_mtime, sha256, status, threat_name, time.time()),
)
conn.commit()
def stats(self) -> dict:
"""Статистика кэша."""
conn = self._conn()
total = conn.execute("SELECT COUNT(*) FROM scan_cache").fetchone()[0]
malware = conn.execute(
"SELECT COUNT(*) FROM scan_cache WHERE status = 'MALWARE'"
).fetchone()[0]
return {"total": total, "malware": malware}
======================================================================МОДУЛЬ: Конфигурация агентаФАЙЛ: windows-agent/core/config.py======================================================================
# -*- coding: utf-8 -*-
"""Конфигурация Windows-агента AV Platform."""
import os
import sys
from pathlib import Path
APP_NAME = "AV Platform Agent"
APP_VERSION = "1.0.0"
# API backend (через SSH-туннель или прямой доступ)
API_BASE = os.environ.get("AV_API_BASE", "http://127.0.0.1:8450/api/v1")
# Админ-токен (для backup export/import и служебных операций)
ADMIN_TOKEN = os.environ.get("AV_ADMIN_TOKEN", "av-admin-token-7c4e9b2a8d1f6e3c5a0b")
# ── Multi-Mirror Geo-Failover ─────────────────────────────────
# Порядок: Primary → Mirror KZ → Mirror EU → локальный фолбэк.
# HTTP-клиент (license.py/backup) идёт по списку сверху вниз,
# пока не получит живой ответ (2xx). Мёртвый primary → живое зеркало.
API_ENDPOINTS = [
"http://127.0.0.1:8450", # PRIMARY (локальный/основной узел)
"https://api.cifra-secure.kz", # MIRROR KZ (standby)
"https://api.cifra-secure.eu", # MIRROR EU (standby)
"http://127.0.0.1:8450", # LOCAL FALLBACK
]
# Offline Grace Mode: сколько дней работать без связи с сервером
OFFLINE_GRACE_DAYS = 14
# Лицензионный ключ (хранится локально)
CONFIG_DIR = Path(os.environ.get("APPDATA", "~")) / "AVPlatformAgent"
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
KEY_FILE = CONFIG_DIR / "license.key"
CACHE_DB = CONFIG_DIR / "scan_cache.sqlite"
QUARANTINE_DIR = CONFIG_DIR / "quarantine"
QUARANTINE_DIR.mkdir(parents=True, exist_ok=True)
VAULT_FILE = CONFIG_DIR / "vault.dat"
LOG_FILE = CONFIG_DIR / "agent.log"
OFFLINE_CACHE_FILE = CONFIG_DIR / "offline_license_cache.json"
# Папки для мониторинга (критические пути пользователя)
def critical_folders() -> list[Path]:
"""Критические папки для реального мониторинга."""
home = Path.home()
folders = [
home / "Downloads",
home / "AppData" / "Local" / "Temp",
home / "AppData" / "Roaming" / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup",
home / "AppData" / "Local",
]
return [f for f in folders if f.exists()]
# Порог: не сканировать файлы больше N МБ (эвристика)
MAX_SCAN_SIZE_MB = 100
# Интервал фонового heartbeat (сек)
HEARTBEAT_INTERVAL = 300
======================================================================МОДУЛЬ: GUI агентаФАЙЛ: windows-agent/agent.py======================================================================
# -*- coding: utf-8 -*-
"""AV Platform Agent — сверхлёгкий Windows-агент (User-Mode, «Установил и забыл»).
GUI в стиле ESET HOME: 920x600, тёмная/светлая тема, RU/EN, 4 вкладки.
"""
import os
import sys
import threading
import tkinter as tk
from pathlib import Path
from tkinter import ttk, messagebox, simpledialog
# Ядро агента
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from core import config
from core.cache import ScanCache
from core.scanner import scan_file
from core.monitor import FileMonitor
from core import quarantine
from core import license as lic
from core import vault
# ── Цвета (ESET HOME) ────────────────────────────────────────
DARK = {"bg": "#0B1120", "card": "#1E293B", "fg": "#E2E8F0", "muted": "#94A3B8",
"accent": "#10B981", "border": "#334155"}
LIGHT = {"bg": "#F8FAFC", "card": "#FFFFFF", "fg": "#1E293B", "muted": "#64748B",
"accent": "#10B981", "border": "#E2E8F0"}
I18N = {
"ru": {
"app": "CIFRA SECURE • Global Edition",
"tab_overview": "Обзор",
"tab_scan": "Проверка и Карантин",
"tab_vault": "Менеджер паролей",
"tab_license": "Лицензия и Настройки",
"status_active": "Защита активна",
"status_inactive": "Защита неактивна",
"shield_ok": "🛡️ Защищено",
"shield_off": "⚠️ Не защищено",
"ram": "Потребление ОЗУ",
"quick_scan": "Быстрая проверка",
"scanning": "Проверка…",
"scanned": "Проверено файлов",
"threats": "Угроз найдено",
"quarantine": "Карантин",
"restore": "Восстановить",
"delete": "Удалить",
"no_threats": "Угроз не обнаружено",
"vault_title": "Менеджер паролей (AES-256)",
"vault_master": "Мастер-пароль",
"vault_add": "+ Добавить запись",
"vault_sync": "Синхронизировать с облаком",
"vault_site": "Сайт",
"vault_login": "Логин",
"vault_pass": "Пароль",
"license_key": "Лицензионный ключ",
"license_activate": "Активировать",
"license_stack": "Суммировать ключ",
"license_switch": "Сменить тариф",
"theme": "Тема",
"language": "Язык",
"who_calls": "Защита звонков (Who Calls) & Антиспам",
"privacy_shield": "Монитор приватности (Privacy Shield)",
"privacy_lines": "Анонимная активация (Без Email, телефона и анкет)|Слежка и телеметрия: 0% (Отключены на уровне ядра)|Личные файлы не покидают устройство (Проверка только по SHA-256)",
"zero_telemetry": "🕵️♂️ ZERO-TELEMETRY (Без слежки и сбора данных)",
"restore_whitelist": "✅ Восстановить + В белый список",
"exclusions_title": "Исключения (Белый список)",
"excl_path": "Путь",
"excl_remove": "Удалить из списка",
"restored_whitelisted_msg": "Файл восстановлен и добавлен в белый список. Проверка пропускается.",
"pause_protection": "⏸️ Пауза защиты",
"pause_15m": "Пауза: 15 минут",
"pause_1h": "Пауза: 1 час",
"pause_reboot": "Пауза: До перезагрузки",
"resume_protection": "▶️ Возобновить защиту",
"shield_paused": "⏸️ Защита на паузе",
"paused_until": "Пауза до",
"device": "Устройство",
"days_left": "Осталось дней",
"tariff": "Тариф",
},
"en": {
"app": "CIFRA SECURE • Global Edition",
"tab_overview": "Overview",
"tab_scan": "Scan & Quarantine",
"tab_vault": "Password Manager",
"tab_license": "License & Settings",
"status_active": "Protection Active",
"status_inactive": "Protection Inactive",
"shield_ok": "🛡️ Protected",
"shield_off": "⚠️ Not Protected",
"ram": "RAM Usage",
"quick_scan": "Quick Scan",
"scanning": "Scanning…",
"scanned": "Files Scanned",
"threats": "Threats Found",
"quarantine": "Quarantine",
"restore": "Restore",
"delete": "Delete",
"no_threats": "No threats detected",
"vault_title": "Password Manager (AES-256)",
"vault_master": "Master Password",
"vault_add": "+ Add Entry",
"vault_sync": "Sync with Cloud",
"vault_site": "Site",
"vault_login": "Login",
"vault_pass": "Password",
"license_key": "License Key",
"license_activate": "Activate",
"license_stack": "Stack Key",
"license_switch": "Switch Plan",
"theme": "Theme",
"language": "Language",
"who_calls": "Secure VPN & Caller ID",
"privacy_shield": "Privacy Shield",
"privacy_lines": "Anonymous activation (No email, phone, or forms)|Tracking & telemetry: 0% (Disabled at core level)|Personal files never leave device (SHA-256 check only)",
"zero_telemetry": "🕵️♂️ ZERO-TELEMETRY (100% Private)",
"restore_whitelist": "✅ Restore & Whitelist",
"exclusions_title": "Exclusions (Whitelist)",
"excl_path": "Path",
"excl_remove": "Remove from list",
"restored_whitelisted_msg": "File restored and added to whitelist. Scan will be skipped.",
"pause_protection": "⏸️ Pause Protection",
"pause_15m": "Pause: 15 minutes",
"pause_1h": "Pause: 1 hour",
"pause_reboot": "Pause: Until Reboot",
"resume_protection": "▶️ Resume Protection",
"shield_paused": "⏸️ Protection Paused",
"paused_until": "Paused until",
"device": "Device",
"days_left": "Days Left",
"tariff": "Plan",
},
}
class AgentApp(tk.Tk):
"""Главное окно агента."""
def __init__(self):
super().__init__()
self.lang = "ru"
self.theme_name = "dark"
self.colors = DARK
self.title(I18N[self.lang]["app"])
self.geometry("920x600")
self.minsize(920, 600)
self.configure(bg=self.colors["bg"])
self.cache = ScanCache()
self.device_id = lic.get_device_id()
self.license_key = lic.load_key()
self.monitor = None
self._build_ui()
self._apply_theme()
def t(self, key):
return I18N[self.lang].get(key, key)
# ── Построение UI ────────────────────────────────────────
def _build_ui(self):
# Левая панель (вкладки)
self.sidebar = tk.Frame(self, width=200, bg=self.colors["card"])
self.sidebar.pack(side="left", fill="y")
self.sidebar.pack_propagate(False)
self.logo = tk.Label(self.sidebar, text="A", font=("Segoe UI", 24, "bold"),
bg=self.colors["accent"], fg="white", width=2)
self.logo.pack(pady=(20, 5))
self.tab_buttons = {}
for i, key in enumerate(["tab_overview", "tab_scan", "tab_vault", "tab_license"]):
btn = tk.Button(self.sidebar, text=self.t(key), anchor="w",
font=("Segoe UI", 11), relief="flat", bd=0,
padx=20, pady=12, cursor="hand2",
command=lambda k=key: self.show_tab(k))
btn.pack(fill="x")
self.tab_buttons[key] = btn
# Переключатель темы/языка внизу сайдбара
ctrl = tk.Frame(self.sidebar, bg=self.colors["card"])
ctrl.pack(side="bottom", fill="x", padx=10, pady=10)
self.theme_btn = tk.Button(ctrl, text="◐", font=("Segoe UI", 14),
relief="flat", bd=0, cursor="hand2",
command=self.toggle_theme)
self.theme_btn.pack(side="left", padx=(0, 5))
self.lang_btn = tk.Button(ctrl, text="RU/EN", font=("Segoe UI", 9),
relief="flat", bd=0, cursor="hand2",
command=self.toggle_lang)
self.lang_btn.pack(side="left")
# Правая область (контент вкладок)
self.content = tk.Frame(self, bg=self.colors["bg"])
self.content.pack(side="right", fill="both", expand=True)
self.frames = {}
self._build_overview()
self._build_scan()
self._build_vault()
self._build_license()
self.show_tab("tab_overview")
def _card(self, parent):
return tk.Frame(parent, bg=self.colors["card"], padx=20, pady=16,
highlightbackground=self.colors["border"], highlightthickness=1)
def _build_overview(self):
f = tk.Frame(self.content, bg=self.colors["bg"], padx=24, pady=24)
self.frames["tab_overview"] = f
# Щит статуса
self.shield_label = tk.Label(f, text=self.t("shield_off"),
font=("Segoe UI", 28, "bold"),
bg=self.colors["bg"], fg=self.colors["fg"])
self.shield_label.pack(pady=(10, 4))
self.status_label = tk.Label(f, text=self.t("status_inactive"),
font=("Segoe UI", 14),
bg=self.colors["bg"], fg=self.colors["muted"])
self.status_label.pack()
# Карточки статистики
stats = tk.Frame(f, bg=self.colors["bg"])
stats.pack(fill="x", pady=20)
self.ram_var = tk.StringVar(value="— МБ")
self.scanned_var = tk.StringVar(value="0")
self.threats_var = tk.StringVar(value="0")
self._stat_card(stats, self.t("ram"), self.ram_var, 0)
self._stat_card(stats, self.t("scanned"), self.scanned_var, 1)
self._stat_card(stats, self.t("threats"), self.threats_var, 2)
# Кнопка быстрой проверки
self.scan_btn = tk.Button(f, text=self.t("quick_scan"),
font=("Segoe UI", 12, "bold"),
bg=self.colors["accent"], fg="white",
relief="flat", padx=24, pady=10, cursor="hand2",
command=self.quick_scan)
self.scan_btn.pack(pady=10)
# Пауза защиты (ESET-style dropdown)
pause_row = tk.Frame(f, bg=self.colors["bg"])
pause_row.pack(pady=(2, 0))
self.pause_btn = tk.Button(pause_row, text=self.t("pause_protection"),
font=("Segoe UI", 11),
bg=self.colors["card"], fg=self.colors["fg"],
relief="flat", padx=16, pady=6, cursor="hand2",
command=self._show_pause_menu)
self.pause_btn.pack(side="left", padx=4)
self.pause_countdown = tk.Label(pause_row, text="", font=("Segoe UI", 10),
bg=self.colors["bg"], fg="#F59E0B")
self.pause_countdown.pack(side="left", padx=4)
self._pause_menu = None
# Who Calls / VPN (условная локализация)
self.feature_label = tk.Label(f, text="📞 " + self.t("who_calls"),
font=("Segoe UI", 11),
bg=self.colors["bg"], fg=self.colors["accent"])
self.feature_label.pack(pady=(10, 0))
# Privacy Shield (Zero-Telemetry)
shield_card = tk.Frame(f, bg=self.colors["card"], padx=16, pady=12,
highlightbackground=self.colors["border"], highlightthickness=1)
shield_card.pack(fill="x", pady=(16, 0))
tk.Label(shield_card, text="🕵️♂️ " + self.t("privacy_shield"),
font=("Segoe UI", 11, "bold"),
bg=self.colors["card"], fg=self.colors["fg"]).pack(anchor="w", pady=(0, 6))
for line in self.t("privacy_lines").split("|"):
tk.Label(shield_card, text="✔️ " + line.strip(),
font=("Segoe UI", 9),
bg=self.colors["card"], fg=self.colors["accent"]).pack(anchor="w", pady=1)
# Zero-Telemetry badge (статус-бар внизу)
self.badge_label = tk.Label(f, text=self.t("zero_telemetry"),
font=("Segoe UI", 9),
bg=self.colors["bg"], fg=self.colors["muted"])
self.badge_label.pack(side="bottom", pady=(10, 0))
def _stat_card(self, parent, title, var, col):
card = tk.Frame(parent, bg=self.colors["card"], padx=16, pady=12,
highlightbackground=self.colors["border"], highlightthickness=1)
card.grid(row=0, column=col, padx=6, sticky="nsew")
parent.columnconfigure(col, weight=1)
tk.Label(card, text=title, font=("Segoe UI", 9),
bg=self.colors["card"], fg=self.colors["muted"]).pack(anchor="w")
tk.Label(card, textvariable=var, font=("Segoe UI", 18, "bold"),
bg=self.colors["card"], fg=self.colors["fg"]).pack(anchor="w")
def _build_scan(self):
f = tk.Frame(self.content, bg=self.colors["bg"], padx=24, pady=24)
self.frames["tab_scan"] = f
tk.Label(f, text=self.t("quarantine"), font=("Segoe UI", 16, "bold"),
bg=self.colors["bg"], fg=self.colors["fg"]).pack(anchor="w", pady=(0, 10))
cols = ("name", "path", "action")
self.q_tree = ttk.Treeview(f, columns=cols, show="headings", height=14)
self.q_tree.heading("name", text="Имя")
self.q_tree.heading("path", text="Путь")
self.q_tree.heading("action", text="")
self.q_tree.column("name", width=180)
self.q_tree.column("path", width=420)
self.q_tree.column("action", width=120)
self.q_tree.pack(fill="both", expand=True)
btns = tk.Frame(f, bg=self.colors["bg"])
btns.pack(fill="x", pady=8)
tk.Button(btns, text=self.t("restore"), command=self.restore_selected,
relief="flat", padx=12, pady=6, cursor="hand2").pack(side="left", padx=4)
tk.Button(btns, text=self.t("restore_whitelist"), command=self.restore_and_whitelist,
bg="#10B981", fg="white", relief="flat", padx=12, pady=6,
cursor="hand2").pack(side="left", padx=4)
tk.Button(btns, text=self.t("delete"), command=self.delete_selected,
relief="flat", padx=12, pady=6, cursor="hand2").pack(side="left", padx=4)
tk.Button(btns, text="Обновить", command=self.refresh_quarantine,
relief="flat", padx=12, pady=6, cursor="hand2").pack(side="right", padx=4)
self.refresh_quarantine()
# ── Список исключений (Exclusions / белый список) ────────
tk.Label(f, text=self.t("exclusions_title"), font=("Segoe UI", 13, "bold"),
bg=self.colors["bg"], fg=self.colors["fg"]).pack(anchor="w", pady=(14, 6))
ecols = ("epath", "esha")
self.ex_tree = ttk.Treeview(f, columns=ecols, show="headings", height=6)
self.ex_tree.heading("epath", text=self.t("excl_path"))
self.ex_tree.heading("esha", text="SHA-256")
self.ex_tree.column("epath", width=420)
self.ex_tree.column("esha", width=220)
self.ex_tree.pack(fill="both", expand=False)
exbtns = tk.Frame(f, bg=self.colors["bg"])
exbtns.pack(fill="x", pady=4)
tk.Button(exbtns, text=self.t("excl_remove"), command=self.remove_exclusion,
relief="flat", padx=12, pady=6, cursor="hand2").pack(side="left", padx=4)
tk.Button(exbtns, text="Обновить", command=self.refresh_exclusions,
relief="flat", padx=12, pady=6, cursor="hand2").pack(side="left", padx=4)
self.refresh_exclusions()
def _build_vault(self):
f = tk.Frame(self.content, bg=self.colors["bg"], padx=24, pady=24)
self.frames["tab_vault"] = f
tk.Label(f, text=self.t("vault_title"), font=("Segoe UI", 16, "bold"),
bg=self.colors["bg"], fg=self.colors["fg"]).pack(anchor="w", pady=(0, 10))
# Мастер-пароль
mrow = tk.Frame(f, bg=self.colors["bg"])
mrow.pack(fill="x", pady=4)
tk.Label(mrow, text=self.t("vault_master"), bg=self.colors["bg"],
fg=self.colors["muted"]).pack(side="left")
self.master_entry = tk.Entry(mrow, show="*", width=24)
self.master_entry.pack(side="right")
# Список записей
self.vault_list = tk.Listbox(f, height=12, font=("Consolas", 10))
self.vault_list.pack(fill="both", expand=True, pady=8)
# Кнопки
vb = tk.Frame(f, bg=self.colors["bg"])
vb.pack(fill="x")
tk.Button(vb, text=self.t("vault_add"), command=self.vault_add,
relief="flat", padx=12, pady=6, cursor="hand2").pack(side="left", padx=4)
tk.Button(vb, text=self.t("vault_sync"), command=self.vault_sync,
relief="flat", padx=12, pady=6, cursor="hand2").pack(side="left", padx=4)
self._refresh_vault_list()
def _build_license(self):
f = tk.Frame(self.content, bg=self.colors["bg"], padx=24, pady=24)
self.frames["tab_license"] = f
tk.Label(f, text=self.t("license_key"), font=("Segoe UI", 16, "bold"),
bg=self.colors["bg"], fg=self.colors["fg"]).pack(anchor="w", pady=(0, 10))
self.key_entry = tk.Entry(f, font=("Consolas", 12), justify="center")
self.key_entry.pack(fill="x", pady=6)
if self.license_key:
self.key_entry.insert(0, self.license_key)
tk.Button(f, text=self.t("license_activate"), command=self.activate_key,
bg=self.colors["accent"], fg="white", relief="flat",
padx=16, pady=8, cursor="hand2").pack(pady=6)
# Инфо о лицензии
self.lic_info = tk.Label(f, text="", font=("Segoe UI", 11),
bg=self.colors["bg"], fg=self.colors["muted"],
justify="left")
self.lic_info.pack(anchor="w", pady=8)
# Суммировать / Сменить тариф
row = tk.Frame(f, bg=self.colors["bg"])
row.pack(fill="x", pady=6)
tk.Button(row, text=self.t("license_stack"), command=self.stack_key,
relief="flat", padx=12, pady=6, cursor="hand2").pack(side="left", padx=4)
tk.Button(row, text=self.t("license_switch"), command=self.switch_key,
relief="flat", padx=12, pady=6, cursor="hand2").pack(side="left", padx=4)
self._refresh_license_info()
# ── Логика ───────────────────────────────────────────────
def show_tab(self, key):
for k, frame in self.frames.items():
frame.pack_forget()
self.tab_buttons[k].configure(bg=self.colors["card"], fg=self.colors["fg"])
self.frames[key].pack(fill="both", expand=True)
self.tab_buttons[key].configure(bg=self.colors["accent"], fg="white")
def toggle_theme(self):
self.theme_name = "light" if self.theme_name == "dark" else "dark"
self.colors = LIGHT if self.theme_name == "light" else DARK
self._apply_theme()
def toggle_lang(self):
self.lang = "en" if self.lang == "ru" else "ru"
self._refresh_texts()
def _apply_theme(self):
self.configure(bg=self.colors["bg"])
self.sidebar.configure(bg=self.colors["card"])
self.content.configure(bg=self.colors["bg"])
self.logo.configure(bg=self.colors["accent"])
for btn in self.tab_buttons.values():
btn.configure(bg=self.colors["card"], fg=self.colors["fg"],
activebackground=self.colors["accent"], activeforeground="white")
# Перерисовать содержимое
self._refresh_texts()
def _refresh_texts(self):
self.title(self.t("app"))
for k, btn in self.tab_buttons.items():
btn.configure(text=self.t(k))
# Обновляем статус
self._update_status()
def _update_status(self):
if self.license_key:
self.shield_label.configure(text=self.t("shield_ok"), fg=self.colors["accent"])
self.status_label.configure(text=self.t("status_active"))
else:
self.shield_label.configure(text=self.t("shield_off"))
self.status_label.configure(text=self.t("status_inactive"))
def quick_scan(self):
"""Быстрая проверка критических папок (в фоне)."""
self.scan_btn.configure(state="disabled", text=self.t("scanning"))
self.scanned_var.set("0")
self.threats_var.set("0")
def worker():
scanned = 0
threats = 0
for folder in config.critical_folders():
for path in folder.rglob("*"):
if path.is_file():
result = scan_file(path, self.license_key)
scanned += 1
if result["status"] == "MALWARE":
threats += 1
quarantine.quarantine_file(path)
self.after(0, lambda s=scanned, th=threats: (
self.scanned_var.set(str(s)),
self.threats_var.set(str(th)),
))
self.after(0, lambda: (
self.scan_btn.configure(state="normal", text=self.t("quick_scan")),
self.refresh_quarantine(),
messagebox.showinfo(self.t("quick_scan"),
f"{self.t('scanned')}: {scanned}\n{self.t('threats')}: {threats}"),
))
threading.Thread(target=worker, daemon=True).start()
def refresh_quarantine(self):
for i in self.q_tree.get_children():
self.q_tree.delete(i)
for item in quarantine.list_quarantine():
self.q_tree.insert("", "end", iid=item["quarantine_name"],
values=(item["original_name"], item["original_path"], ""))
def restore_selected(self):
sel = self.q_tree.selection()
if sel:
quarantine.restore_file(sel[0])
self.refresh_quarantine()
def delete_selected(self):
sel = self.q_tree.selection()
if sel and messagebox.askyesno(self.t("delete"), sel[0]):
quarantine.delete_from_quarantine(sel[0])
self.refresh_quarantine()
# ── Белый список (Exclusions) ──────────────────────────────
def restore_and_whitelist(self):
"""Восстановить из карантина И сразу добавить в белый список (1 клик)."""
sel = self.q_tree.selection()
if not sel:
return
res = quarantine.restore_file(sel[0])
if res.get("success"):
restored = Path(res["restored_to"])
# sha256 уже известен? берём из кэша, иначе compute
sha = None
cached = self.cache.get(restored)
if cached is not None:
sha = cached["sha256"]
self.cache.add_whitelist(restored, sha)
# Сбрасываем статус в кэше, чтобы файл считался CLEAN
messagebox.showinfo(self.t("restore_whitelist"),
self.t("restored_whitelisted_msg"))
self.refresh_quarantine()
self.refresh_exclusions()
def refresh_exclusions(self):
for i in self.ex_tree.get_children():
self.ex_tree.delete(i)
for item in self.cache.list_whitelist():
sha = (item.get("sha256") or "")[:16]
self.ex_tree.insert("", "end", iid=item["file_path"],
values=(item["file_path"], sha))
def remove_exclusion(self):
sel = self.ex_tree.selection()
if sel:
self.cache.remove_whitelist(sel[0])
self.refresh_exclusions()
# ── Пауза защиты (ESET-style) ──────────────────────────────
def _show_pause_menu(self):
"""Выпадающее меню паузы защиты."""
menu = tk.Menu(self, tearoff=0, bg=self.colors["card"], fg=self.colors["fg"])
if self.monitor and self.monitor.is_paused():
menu.add_command(label=self.t("resume_protection"),
command=self.resume_protection)
else:
menu.add_command(label=self.t("pause_15m"),
command=lambda: self.pause_protection(15 * 60))
menu.add_command(label=self.t("pause_1h"),
command=lambda: self.pause_protection(60 * 60))
menu.add_command(label=self.t("pause_reboot"),
command=lambda: self.pause_protection(None))
# Позиционировать под кнопкой
x = self.pause_btn.winfo_rootx()
y = self.pause_btn.winfo_rooty() + self.pause_btn.winfo_height()
menu.tk_popup(x, y)
def pause_protection(self, seconds: float | None):
"""Поставить защиту на паузу (seconds=None → до перезагрузки)."""
if self.monitor:
self.monitor.pause(seconds)
self._update_shield()
self._tick_pause()
def resume_protection(self):
"""Возобновить защиту."""
if self.monitor:
self.monitor.resume()
self.pause_countdown.config(text="")
self._update_shield()
def _update_shield(self):
"""Обновить щит: зелёный (активна) / амбер #F59E0B (пауза) / серый (выкл)."""
if self.monitor and self.monitor.is_paused():
self.shield_label.config(text=self.t("shield_paused"), fg="#F59E0B")
self.status_label.config(text=self.t("pause_protection"), fg="#F59E0B")
elif self.monitor and self.monitor._thread and self.monitor._thread.is_alive():
self.shield_label.config(text=self.t("shield_ok"), fg="#10B981")
self.status_label.config(text=self.t("status_active"), fg=self.colors["muted"])
else:
self.shield_label.config(text=self.t("shield_off"), fg=self.colors["fg"])
self.status_label.config(text=self.t("status_inactive"), fg=self.colors["muted"])
def _tick_pause(self):
"""Таймер обратного отсчёта паузы (обновление раз в секунду)."""
if self.monitor and self.monitor.is_paused():
rem = self.monitor.pause_remaining()
if rem == float("inf"):
self.pause_countdown.config(text="⏸ " + self.t("pause_reboot"))
else:
m, s = divmod(int(rem), 60)
h, m = divmod(m, 60)
self.pause_countdown.config(text=f"⏸ {h:02d}:{m:02d}:{s:02d}")
self.after(1000, self._tick_pause)
else:
self.pause_countdown.config(text="")
self._update_shield()
def _refresh_vault_list(self):
self.vault_list.delete(0, "end")
for e in vault.load_local():
self.vault_list.insert("end", f"{e.get('site','')} · {e.get('login','')}")
def vault_add(self):
site = simpledialog.askstring(self.t("vault_site"), self.t("vault_site"))
if not site:
return
login = simpledialog.askstring(self.t("vault_login"), self.t("vault_login"))
pwd = simpledialog.askstring(self.t("vault_pass"), self.t("vault_pass"), show="*")
entries = vault.load_local()
entries.append({"site": site, "login": login or "", "password": pwd or ""})
vault.save_local(entries)
self._refresh_vault_list()
def vault_sync(self):
mp = self.master_entry.get()
if not mp:
messagebox.showwarning(self.t("vault_master"), self.t("vault_master"))
return
if not self.license_key:
messagebox.showwarning(self.t("license_key"), self.t("license_key"))
return
try:
vault.sync_to_server(self.license_key, mp)
messagebox.showinfo(self.t("vault_sync"), "OK")
except Exception as e:
messagebox.showerror(self.t("vault_sync"), str(e))
def activate_key(self):
key = self.key_entry.get().strip()
if len(key) < 19:
messagebox.showwarning(self.t("license_key"), self.t("license_key"))
return
try:
lic.activate(key, self.device_id, "Windows PC")
self.license_key = key
lic.save_key(key)
self._save_key_backup(key)
self._update_status()
self._refresh_license_info()
messagebox.showinfo(self.t("license_activate"), self.t("status_active"))
except Exception as e:
messagebox.showerror(self.t("license_activate"), str(e))
def _save_key_backup(self, key: str):
"""Авто-сохранить памятку CIFRA_SECURE_KEY_BACKUP.txt (+ на D:\\ если есть)."""
content = (
"=== CIFRA SECURE | СЕРТИФИКАТ ЛИЦЕНЗИИ И ВОССТАНОВЛЕНИЯ ===\n\n"
f"Ваш ключ активации: {key}\n"
"Устройство (HWID): " + self.device_id + "\n\n"
"Способы восстановления (без Email):\n"
" 1) Авто-по железу (HWID) — запустите агент без ключа на этом же ПК.\n"
" 2) По числу восстановления XXX-XXX-XX.\n"
" 3) По связке: слово + 4 цифры.\n\n"
"⚠️ Сохраните этот файл! Мы не храним ваши личные данные (Zero-Telemetry).\n"
"Активируйте ключ в день покупки.\nCIFRA SECURE • Global Edition\n"
)
targets = [Path.home() / "CIFRA_SECURE_KEY_BACKUP.txt"]
d_drive = Path("D:/")
if d_drive.exists():
targets.append(d_drive / "CIFRA_SECURE_KEY_BACKUP.txt")
for tpath in targets:
try:
tpath.write_text(content, encoding="utf-8")
except Exception:
pass
def stack_key(self):
new_key = simpledialog.askstring(self.t("license_stack"), "XXXX-XXXX-XXXX-XXXX")
if not new_key or not self.license_key:
return
try:
lic.stack_key(self.license_key, new_key.strip())
self._refresh_license_info()
messagebox.showinfo(self.t("license_stack"), "OK")
except Exception as e:
messagebox.showerror(self.t("license_stack"), str(e))
def switch_key(self):
new_key = simpledialog.askstring(self.t("license_switch"), "XXXX-XXXX-XXXX-XXXX")
if not new_key or not self.license_key:
return
try:
lic.switch_key(self.license_key, new_key.strip())
self._refresh_license_info()
messagebox.showinfo(self.t("license_switch"), "OK")
except Exception as e:
messagebox.showerror(self.t("license_switch"), str(e))
def _refresh_license_info(self):
if not self.license_key:
self.lic_info.configure(text="")
return
try:
info = lic.get_license(self.license_key)
edition = info.get("edition", "?")
days = "—"
self.lic_info.configure(
text=f"{self.t('tariff')}: {edition} · {self.t('device')}: {self.device_id[:8]}…"
)
except Exception as e:
self.lic_info.configure(text=str(e))
def main():
app = AgentApp()
# Запуск фонового мониторинга
app.monitor = FileMonitor(app.cache, app.license_key)
app.monitor.start()
# Обновление счётчиков ОЗУ
def update_ram():
try:
import psutil
mb = psutil.Process(os.getpid()).memory_info().rss / (1024 * 1024)
app.ram_var.set(f"{mb:.1f} МБ")
except Exception:
app.ram_var.set("— МБ")
app.after(3000, update_ram)
app.after(1000, update_ram)
# ── Системный трей (pystray + Pillow, с fallback) ──────────
def setup_tray():
try:
import threading
import pystray
from PIL import Image, ImageDraw
# Рисуем простой shield-икон 64x64
img = Image.new("RGB", (64, 64), "#0B1120")
d = ImageDraw.Draw(img)
d.polygon([(32, 8), (54, 18), (54, 34), (32, 56), (10, 34), (10, 18)],
fill="#10B981")
def open_window(icon, item):
app.after(0, app.deiconify)
def pause(sec):
def _h(icon=None, item=None):
app.after(0, lambda: app.pause_protection(sec))
return _h
def resume(icon=None, item=None):
app.after(0, app.resume_protection)
menu = pystray.Menu(
pystray.MenuItem(app.t("tab_overview"), open_window, default=True),
pystray.Menu.SEPARATOR,
pystray.MenuItem(app.t("pause_15m"), pause(15 * 60)),
pystray.MenuItem(app.t("pause_1h"), pause(60 * 60)),
pystray.MenuItem(app.t("pause_reboot"), pause(None)),
pystray.MenuItem(app.t("resume_protection"), resume),
)
icon = pystray.Icon("AVPlatform", img, app.t("app"), menu)
# Минимизировать в трей при закрытии окна
app.protocol("WM_DELETE_WINDOW", lambda: (app.withdraw(), None))
threading.Thread(target=icon.run, daemon=True).start()
except Exception:
# Fallback: трей недоступен (headless/нет pystray) — работаем без него
pass
setup_tray()
app.mainloop()
if __name__ == "__main__":
main()