Blog

Scheduled and recurring autonomous pentests: how the Darkmoon scheduler works

A background task polls every 60 seconds for due campaigns and launches the pentest orchestrator, with none, daily, weekly or monthly recurrence and one-shot schedules that disable themselves after firing. The exact model, from the API routes and the recurrence logic as implemented.

· 7 min read

A pentest you run once is a photograph. A pentest you run on a schedule is a monitor. Darkmoon ships a scheduler for exactly that reason, and this article describes it as it is implemented in the platform’s API, not as a roadmap. The short version: a background task wakes up every 60 seconds, finds the campaigns that are due, and launches the same autonomous orchestrator the dashboard uses, with a recurrence you choose.

How a schedule is stored

Scheduled campaigns are persisted as JSON and managed through a small CRUD API. Creating one takes a target plus the same options a manual run accepts, and a couple of scheduling fields: when it should first run, and how often it should repeat.

FieldMeaning
scheduled_atISO-8601 datetime for the first execution
recurrencenone, daily, weekly or monthly
recurrence_intervalRun every N periods, e.g. every 2 weeks
enabledWhether the schedule is active
target / focus / severity / format …The same campaign options a manual run takes
remediate / git_repo / credential_idOptional: enable the remediation phase (Pro)

The scheduling fields sit alongside the full campaign definition, so a schedule is just a saved run with a clock attached. The record also tracks last_run, next_run and a run_count, so you can see a schedule’s history at a glance.

The loop that fires them

The scheduler is a long-lived asyncio task started from the API’s lifespan. Every 60 seconds it reads the schedules, compares each one’s next_run to the current time, and for any that are due and enabled, it launches the campaign without blocking the loop.

async def scheduler_loop():
    while True:
        schedules = _read_schedules()
        now = datetime.now(timezone.utc)
        for entry in schedules:
            if not entry.get("enabled", True):
                continue
            next_run = datetime.fromisoformat(entry["next_run"])
            if next_run <= now:
                asyncio.create_task(_trigger_campaign(entry))
                entry["last_run"] = now.isoformat()
                entry["run_count"] = entry.get("run_count", 0) + 1
                nxt = _compute_next_run(entry)
                entry["next_run"] = nxt          # None for a one-shot
                entry["enabled"] = nxt is not None or entry["enabled"]
        await asyncio.sleep(60)

Two design choices are worth calling out. First, the campaign is fired as its own task, so a slow run never stalls the polling loop or delays other due schedules. Second, a schedule with a recurrence of none is a genuine one-shot: after it fires, its next run is cleared and it disables itself, so it cannot run twice.

What "launch a campaign" actually means

When a schedule is due, the scheduler builds the same CLI prompt a manual run would, and spawns the orchestrator exactly as the interactive runner does:

opencode run --agent pentest --format json "TARGET: ... FOCUS=... SEVERITY=..."

The output is streamed line by line into a per-run JSONL log, beginning with a run_started event that records the command and the schedule id, and ending with a run_completed or run_error event. That log is the single source of truth for the run’s state, and it is the same format the live dashboard reads, so a scheduled run is indistinguishable from a manual one once it is underway. From there the orchestrator dispatches its specialist agents, pushes findings as it goes, and finalizes to a report.

Recurrence, precisely

  • daily advances the next run by recurrence_interval days.
  • weekly advances it by that many weeks.
  • monthly advances it by 30 days per interval. This is a deliberate approximation: the scheduler adds 30 days rather than tracking calendar months, so "monthly" means "every 30 days". If exact calendar-day alignment matters to you, prefer a weekly cadence or set the datetime explicitly.

Honest limits

The scheduler is a single-node background loop with a 60-second resolution, so a campaign fires within a minute of its due time, not to the second. Schedules are persisted to a JSON file, which is simple and auditable but is node-local state, not a distributed job queue. And a scheduled run inherits every constraint of a manual run, including the credential-gated, manual-only nature of any dispatch that needs secrets. None of that is a limitation for the intended use, recurring validation of a known target, but it is worth stating plainly.

Where this fits

Scheduled runs are the "time-driven" half of continuous validation. The "event-driven" half is wiring a run into your pipeline so a deploy triggers a test; that is covered in autonomous penetration testing in your CI/CD pipeline. Both produce the same live-updating dashboard view, described in watching findings land as the agent works, and the same deterministic report.

Darkmoon is our open source project (GPL-3.0): github.com/ASCIT31/Dark-Moon, docs.

Run it against your own lab

Darkmoon is open source (GPL-3.0) and self hosted. Clone it, point it at a target you own, and read every line.