Catalyst / admin/Synapse-NetscanXi 14.8 GB / 57.8 GB 40.0 GB free
Help Sign in

admin / Synapse-NetscanXi

public

Network Scanning, Vulnerability and Compliance Application

Code Issues Pull requests Pipelines Packages Security Insights Wiki Settings
Synapse-NetscanXi / NetscanXiVersion13 / app / scheduler.py 6128 B · main
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
"""
Scheduler for recurring scans (v6).

UI-managed: schedules are persisted in the `schedules` table and edited from the
web app. A single background thread ticks every ~15s, and any schedule whose
`next_run` is due gets enqueued.

Backward compatibility: the legacy env-configured single schedule
(NETSCAN_SCHEDULE_MINUTES / _TARGET / _PROFILE) is still honoured. On first
start, if those env vars define a schedule and the table is empty, it is seeded
into the DB as a normal editable schedule.
"""

import os
import threading
import time
from datetime import datetime, timedelta

from . import db

TICK_SECONDS = 15


def next_weekly_run(days, hour, minute, after=None):
    """Compute the next epoch timestamp matching one of `days` (0=Mon..6=Sun)
    at local hour:minute, strictly after `after` (defaults to now)."""
    if not days:
        return None
    base = datetime.fromtimestamp(after if after is not None else time.time())
    hour = int(hour or 0)
    minute = int(minute or 0)
    for add in range(0, 8):
        cand = (base + timedelta(days=add)).replace(
            hour=hour, minute=minute, second=0, microsecond=0)
        if cand.weekday() in days and cand.timestamp() > base.timestamp():
            return cand.timestamp()
    return None


class Scheduler:
    def __init__(self, manager, default_target_fn, db_path=None):
        self.manager = manager
        self.default_target_fn = default_target_fn
        self.db_path = db_path or db.DEFAULT_DB_PATH
        self._stop = threading.Event()
        self._thread = None
        self._seed_from_env()

    # -- legacy env seed ---------------------------------------------------
    def _seed_from_env(self):
        try:
            interval = int(os.environ.get("NETSCAN_SCHEDULE_MINUTES", "0") or "0")
        except ValueError:
            interval = 0
        if interval <= 0:
            return
        # Only seed if there are no schedules yet (don't duplicate on restart).
        if db.list_schedules(db_path=self.db_path):
            return
        target = os.environ.get("NETSCAN_SCHEDULE_TARGET", "").strip()
        profile = os.environ.get("NETSCAN_SCHEDULE_PROFILE", "standard").strip()
        sid = db.create_schedule(
            name="Default (env)", interval_minutes=interval, target=target,
            profile=profile or "standard", enabled=True, db_path=self.db_path)
        # Prime next_run so it fires after one interval, not immediately.
        db.update_schedule(sid, db_path=self.db_path,
                           next_run=time.time() + interval * 60)

    # -- lifecycle ---------------------------------------------------------
    @property
    def enabled(self):
        """True if any enabled schedule exists."""
        return any(s["enabled"] for s in db.list_schedules(db_path=self.db_path))

    def start(self):
        if self._thread:
            return
        # Ensure every enabled schedule has a next_run set.
        now = time.time()
        for s in db.list_schedules(only_enabled=True, db_path=self.db_path):
            if not s.get("next_run"):
                db.update_schedule(s["id"], db_path=self.db_path,
                                   next_run=self._compute_next(s, after=now))
        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 self._stop.wait(TICK_SECONDS):
                break
            self._run_due()

    @staticmethod
    def _compute_next(s, after=None):
        """Next run for a schedule: weekly day+time if set, else interval."""
        if s.get("days"):
            nr = next_weekly_run(s["days"], s.get("hour"), s.get("minute"),
                                 after=after)
            if nr:
                return nr
        base = after if after is not None else time.time()
        return base + s["interval_minutes"] * 60

    def _run_due(self):
        now = time.time()
        for s in db.list_schedules(only_enabled=True, db_path=self.db_path):
            nxt = s.get("next_run")
            if nxt is None:
                db.update_schedule(s["id"], db_path=self.db_path,
                                   next_run=self._compute_next(s, after=now))
                continue
            if nxt <= now:
                target = s["target"].strip() or self.default_target_fn()
                scan_type = s.get("scan_type") or "active"
                try:
                    self.manager.enqueue(target, s["profile"],
                                         triggered_by="schedule",
                                         options=s.get("options"),
                                         scan_type=scan_type)
                except TypeError:
                    # manager.enqueue without options/scan_type kwarg (defensive)
                    self.manager.enqueue(target, s["profile"],
                                         triggered_by="schedule")
                db.update_schedule(
                    s["id"], db_path=self.db_path, last_run=now,
                    next_run=self._compute_next(s, after=now))

    # -- introspection (used by /api/scheduler and the dashboard header) ----
    def info(self):
        schedules = db.list_schedules(db_path=self.db_path)
        enabled = [s for s in schedules if s["enabled"]]
        # Soonest next_run across enabled schedules drives the header summary.
        next_run = None
        for s in enabled:
            if s.get("next_run") and (next_run is None or s["next_run"] < next_run):
                next_run = s["next_run"]
        first = enabled[0] if enabled else None
        return {
            "enabled": bool(enabled),
            "count": len(schedules),
            "enabled_count": len(enabled),
            "interval_minutes": first["interval_minutes"] if first else 0,
            "target": (first["target"] or "(auto)") if first else "(auto)",
            "profile": first["profile"] if first else "standard",
            "next_run": next_run,
            "schedules": schedules,
        }