216 lines
7.7 KiB
Python
216 lines
7.7 KiB
Python
"""Scheduler for recurring scans and signature updates.
|
|
|
|
Wraps the ``schedule`` library to provide cron-like recurring tasks that
|
|
drive the :class:`ScanEngine` and signature updater in a long-running
|
|
daemon loop.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
from typing import Optional
|
|
|
|
import schedule
|
|
|
|
from ayn_antivirus.config import Config
|
|
from ayn_antivirus.core.engine import ScanEngine, ScanResult
|
|
from ayn_antivirus.core.event_bus import EventType, event_bus
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cron expression helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _parse_cron_field(field: str, min_val: int, max_val: int) -> list[int]:
|
|
"""Parse a single cron field (e.g. ``*/5``, ``1,3,5``, ``0-23``, ``*``).
|
|
|
|
Returns a sorted list of matching integer values.
|
|
"""
|
|
values: set[int] = set()
|
|
|
|
for part in field.split(","):
|
|
part = part.strip()
|
|
|
|
# */step
|
|
if part.startswith("*/"):
|
|
step = int(part[2:])
|
|
values.update(range(min_val, max_val + 1, step))
|
|
# range with optional step (e.g. 1-5 or 1-5/2)
|
|
elif "-" in part:
|
|
range_part, _, step_part = part.partition("/")
|
|
lo, hi = range_part.split("-", 1)
|
|
step = int(step_part) if step_part else 1
|
|
values.update(range(int(lo), int(hi) + 1, step))
|
|
# wildcard
|
|
elif part == "*":
|
|
values.update(range(min_val, max_val + 1))
|
|
# literal
|
|
else:
|
|
values.add(int(part))
|
|
|
|
return sorted(values)
|
|
|
|
|
|
def _cron_to_schedule(cron_expr: str) -> dict:
|
|
"""Convert a 5-field cron expression into components.
|
|
|
|
Returns a dict with keys ``minutes``, ``hours``, ``days``, ``months``,
|
|
``weekdays`` — each a list of integers.
|
|
|
|
Only *minute* and *hour* are used by the ``schedule`` library adapter
|
|
below; the rest are validated but not fully honoured (``schedule`` lacks
|
|
calendar-level granularity).
|
|
"""
|
|
parts = cron_expr.strip().split()
|
|
if len(parts) != 5:
|
|
raise ValueError(f"Expected 5-field cron expression, got: {cron_expr!r}")
|
|
|
|
return {
|
|
"minutes": _parse_cron_field(parts[0], 0, 59),
|
|
"hours": _parse_cron_field(parts[1], 0, 23),
|
|
"days": _parse_cron_field(parts[2], 1, 31),
|
|
"months": _parse_cron_field(parts[3], 1, 12),
|
|
"weekdays": _parse_cron_field(parts[4], 0, 6),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scheduler
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class Scheduler:
|
|
"""Manages recurring scan and update jobs.
|
|
|
|
Parameters
|
|
----------
|
|
config:
|
|
Application configuration — used to build a :class:`ScanEngine` and
|
|
read schedule expressions.
|
|
engine:
|
|
Optional pre-built engine instance. If ``None``, one is created from
|
|
*config*.
|
|
"""
|
|
|
|
def __init__(self, config: Config, engine: Optional[ScanEngine] = None) -> None:
|
|
self.config = config
|
|
self.engine = engine or ScanEngine(config)
|
|
self._scheduler = schedule.Scheduler()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Job builders
|
|
# ------------------------------------------------------------------
|
|
|
|
def schedule_scan(self, cron_expr: str, scan_type: str = "full") -> None:
|
|
"""Schedule a recurring scan using a cron expression.
|
|
|
|
Parameters
|
|
----------
|
|
cron_expr:
|
|
Standard 5-field cron string (``minute hour dom month dow``).
|
|
scan_type:
|
|
One of ``"full"``, ``"quick"``, or ``"deep"``.
|
|
"""
|
|
parsed = _cron_to_schedule(cron_expr)
|
|
|
|
# ``schedule`` doesn't natively support cron, so we approximate by
|
|
# scheduling at every matching hour:minute combination. For simple
|
|
# expressions like ``0 2 * * *`` this is exact.
|
|
for hour in parsed["hours"]:
|
|
for minute in parsed["minutes"]:
|
|
time_str = f"{hour:02d}:{minute:02d}"
|
|
self._scheduler.every().day.at(time_str).do(
|
|
self._run_scan, scan_type=scan_type
|
|
)
|
|
logger.info("Scheduled %s scan at %s daily", scan_type, time_str)
|
|
|
|
def schedule_update(self, interval_hours: int = 6) -> None:
|
|
"""Schedule recurring signature updates.
|
|
|
|
Parameters
|
|
----------
|
|
interval_hours:
|
|
How often (in hours) to pull fresh signatures.
|
|
"""
|
|
self._scheduler.every(interval_hours).hours.do(self._run_update)
|
|
logger.info("Scheduled signature update every %d hour(s)", interval_hours)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Daemon loop
|
|
# ------------------------------------------------------------------
|
|
|
|
def run_daemon(self) -> None:
|
|
"""Start the blocking scheduler loop.
|
|
|
|
Runs all pending jobs and sleeps between iterations. Designed to be
|
|
the main loop of a background daemon process.
|
|
|
|
Press ``Ctrl+C`` (or send ``SIGINT``) to exit cleanly.
|
|
"""
|
|
logger.info("AYN scheduler daemon started — %d job(s)", len(self._scheduler.get_jobs()))
|
|
|
|
try:
|
|
while True:
|
|
self._scheduler.run_pending()
|
|
time.sleep(30)
|
|
except KeyboardInterrupt:
|
|
logger.info("Scheduler daemon stopped by user")
|
|
|
|
# ------------------------------------------------------------------
|
|
# Job implementations
|
|
# ------------------------------------------------------------------
|
|
|
|
def _run_scan(self, scan_type: str = "full") -> None:
|
|
"""Execute a scan job."""
|
|
logger.info("Starting scheduled %s scan", scan_type)
|
|
try:
|
|
if scan_type == "quick":
|
|
result: ScanResult = self.engine.quick_scan()
|
|
else:
|
|
# "full" and "deep" both scan all paths; deep adds process/network
|
|
# via full_scan on the engine, but here we keep it simple.
|
|
result = ScanResult()
|
|
for path in self.config.scan_paths:
|
|
partial = self.engine.scan_path(path, recursive=True)
|
|
result.files_scanned += partial.files_scanned
|
|
result.files_skipped += partial.files_skipped
|
|
result.threats.extend(partial.threats)
|
|
|
|
logger.info(
|
|
"Scheduled %s scan complete — %d files, %d threats",
|
|
scan_type,
|
|
result.files_scanned,
|
|
len(result.threats),
|
|
)
|
|
except Exception:
|
|
logger.exception("Scheduled %s scan failed", scan_type)
|
|
|
|
def _run_update(self) -> None:
|
|
"""Execute a signature update job."""
|
|
logger.info("Starting scheduled signature update")
|
|
try:
|
|
from ayn_antivirus.signatures.manager import SignatureManager
|
|
|
|
manager = SignatureManager(self.config)
|
|
summary = manager.update_all()
|
|
total = summary.get("total_new", 0)
|
|
errors = summary.get("errors", [])
|
|
logger.info(
|
|
"Scheduled signature update complete: %d new, %d errors",
|
|
total,
|
|
len(errors),
|
|
)
|
|
if errors:
|
|
for err in errors:
|
|
logger.warning("Feed error: %s", err)
|
|
manager.close()
|
|
event_bus.publish(EventType.SIGNATURE_UPDATED, {
|
|
"total_new": total,
|
|
"feeds": list(summary.get("feeds", {}).keys()),
|
|
"errors": errors,
|
|
})
|
|
except Exception:
|
|
logger.exception("Scheduled signature update failed")
|