Files
s-b-repo-rustsploit/src/scheduler.rs
T
s-b-repo 94899781d3 Framework hardening: retry-then-continue, panic fixes, WS/MCP bug fixes, no-swallow sweep + loud error surfacing; docs + release notes
Hardening (non-module framework files):
- Retry-then-continue: bounded per-host retry on transient failures across all 4
  mass-scan fan-outs; '10 errors -> abort sweep' softened to warn-and-continue
- Crash fixes: shell completer char-boundary guard; unreachable! -> bail!
- WS oversize-frame desync fixed (was bricking the PQ AEAD ratchet); MCP tenant
  job list/kill, out-of-range port, non-string option now correct/errored
- No silent error swallowing: swept framework files, every dropped error now
  bound + surfaced (warn for logged-only/data-loss, debug for already-propagated
  or aggregated per-host); removed _ => {} and Err(_)/|_| discards

Docs + release: README + docs/ updated for the release; RELEASE_NOTES.txt
section 6d added; new RELEASE_GITHUB.txt (GitHub release body).

Build: 0 errors, 0 warnings, 40/40 targeted tests green.
2026-06-13 02:29:12 +02:00

1760 lines
70 KiB
Rust

// src/scheduler.rs
//
// Unified scheduler. Single entry point for running a `Module` against any
// `Target` shape (single, CIDR, file, multi, random) with hierarchical
// concurrency control, automatic finding routing into
// `LootStore`/`Workspace`/`events`, cancellation, and prompt-cache propagation.
//
// Replaces the three parallel mass-scan loops in
// `src/commands/mod.rs::dispatch_single_target` by collapsing them into one
// streaming engine. Legacy callers can still hit `dispatch_single_target`
// during migration; new code (CLI, shell, API, MCP) calls
// `scheduler::run` directly.
use std::io::IsTerminal;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::time::Duration;
use anyhow::{Context, Result};
use colored::Colorize;
use tokio::sync::Semaphore;
use crate::module::{Finding, FindingKind, Module, ModuleCtx, ModuleOptions, ModuleOutcome, Target};
// ============================================================
// LIMITS
// ============================================================
#[derive(Debug, Clone, Copy)]
pub struct SchedulerLimits {
/// Max concurrent in-flight tasks.
pub concurrency: usize,
/// Per-target deadline in seconds.
pub timeout_secs: u64,
/// Max hosts when fanning out a `Target::Random`.
pub max_random_hosts: usize,
/// Optional pre-check port for random scans.
pub precheck_port: Option<u16>,
/// Refuse IPv6 ranges wider than this.
pub ipv6_max_hosts: u128,
/// Warn / prompt above this IPv4 host count.
pub warn_threshold: u128,
/// Skip hosts that look like honeypots.
pub honeypot_detection: bool,
}
impl Default for SchedulerLimits {
fn default() -> Self {
Self {
concurrency: 50,
timeout_secs: 60,
max_random_hosts: 10_000,
precheck_port: None,
ipv6_max_hosts: 1u128 << 32,
warn_threshold: 65_536,
honeypot_detection: true,
}
}
}
impl SchedulerLimits {
/// Pull defaults from the active tenant's `global_options`.
pub fn from_global_options() -> Self {
let mut l = Self::default();
let scope = crate::tenant::resolve();
let opts = scope.global_options();
if let Some(v) = opts.try_get("concurrency")
.or_else(|| opts.try_get("threads"))
.and_then(|v| v.parse().ok())
{
l.concurrency = v;
}
if let Some(v) = opts.try_get("module_timeout")
.or_else(|| opts.try_get("timeout"))
.and_then(|v| v.parse().ok())
{
l.timeout_secs = v;
}
if let Some(v) = opts.try_get("max_random_hosts").and_then(|v| v.parse().ok()) {
l.max_random_hosts = v;
}
l.precheck_port = opts.try_get("port").and_then(|v| v.parse().ok());
if let Some(v) = opts.try_get("honeypot_detection") {
l.honeypot_detection = !matches!(
v.to_lowercase().as_str(),
"n" | "no" | "false" | "0" | "off" | "disabled"
);
}
l
}
}
// ============================================================
// STATS
// ============================================================
#[derive(Default)]
struct ScanStats {
/// Hosts on which the module ran and returned `Ok` with `success == true`.
success: AtomicUsize,
/// Hosts on which the module errored or returned `success == false`.
failed: AtomicUsize,
/// Hosts skipped before the module ran (precheck miss / honeypot /
/// already-processed checkpoint).
skipped: AtomicUsize,
/// Hosts on which the module actually ran (`success + failed`).
processed: AtomicUsize,
/// Total `Finding`s collected across all hosts. This — not `success` —
/// is the count the operator cares about: "did the scan find anything?".
hits: AtomicUsize,
/// Every host the fan-out looked at this run, whether skipped or run.
/// Drives progress feedback so it advances even when most hosts are
/// precheck-skipped (the common case on random/sparse mass scans).
considered: AtomicUsize,
abort: AtomicBool,
findings: std::sync::Mutex<Vec<Finding>>,
}
/// Point-in-time view of a running scan's counters.
struct StatsSnapshot {
processed: usize,
success: usize,
failed: usize,
skipped: usize,
hits: usize,
considered: usize,
}
impl ScanStats {
fn record(&self, outcome: Result<ModuleOutcome>) -> bool {
match outcome {
Ok(out) => {
if out.success {
self.success.fetch_add(1, Ordering::Relaxed);
} else {
self.failed.fetch_add(1, Ordering::Relaxed);
}
if !out.findings.is_empty() {
self.hits.fetch_add(out.findings.len(), Ordering::Relaxed);
let mut g = self.findings.lock().unwrap_or_else(|e| e.into_inner());
g.extend(out.findings);
}
false
}
Err(e) => {
tracing::debug!("module run failed: {e:#}");
self.failed.fetch_add(1, Ordering::Relaxed);
true
}
}
}
fn snapshot(&self) -> StatsSnapshot {
StatsSnapshot {
processed: self.processed.load(Ordering::Relaxed),
success: self.success.load(Ordering::Relaxed),
failed: self.failed.load(Ordering::Relaxed),
skipped: self.skipped.load(Ordering::Relaxed),
hits: self.hits.load(Ordering::Relaxed),
considered: self.considered.load(Ordering::Relaxed),
}
}
/// Count one host as examined (skipped or run) and report whether a
/// progress line should be emitted now: the first host, then every
/// `step` hosts. Called at the very top of each spawned task — *before*
/// any precheck — so feedback keeps moving on sparse/random ranges
/// where the vast majority of hosts never reach the module.
fn tick(&self, step: usize) -> Option<usize> {
let n = self.considered.fetch_add(1, Ordering::Relaxed) + 1;
if n == 1 || step == 0 || n.is_multiple_of(step) {
Some(n)
} else {
None
}
}
}
/// Choose a progress-report interval so a scan emits a sane number of lines
/// regardless of size: ~tens-to-hundreds of updates rather than one-per-host
/// (spam) or one-per-100k (looks frozen).
fn progress_step_for(total: u128) -> usize {
if total > 10_000_000 {
10_000
} else if total > 100_000 {
1_000
} else if total > 1_000 {
100
} else if total > 100 {
25
} else {
10
}
}
/// Format the shared progress line used by every fan-out so the operator
/// always sees the one number that matters — `hits` — alongside throughput.
fn progress_line(considered: usize, total: u128, s: &StatsSnapshot) -> String {
let pct = if total > 0 {
format!(" ({:.1}%)", (considered as f64 / total as f64) * 100.0)
} else {
String::new()
};
format!(
"[*] Progress: {}/{}{} examined | {} hits | {} ran ({} ok / {} err) | {} skipped",
considered, total, pct, s.hits, s.processed, s.success, s.failed, s.skipped
)
}
// ============================================================
// PUBLIC ENTRY POINTS
// ============================================================
/// Run a module against a target with limits pulled from `global_options`.
pub async fn run(
module: Arc<dyn Module>,
target: Target,
options: ModuleOptions,
verbose: bool,
) -> Result<ModuleOutcome> {
run_with_limits(
module,
target,
options,
verbose,
SchedulerLimits::from_global_options(),
)
.await
}
pub async fn run_with_limits(
module: Arc<dyn Module>,
target: Target,
options: ModuleOptions,
verbose: bool,
limits: SchedulerLimits,
) -> Result<ModuleOutcome> {
run_with_limits_shared(module, target, options, verbose, limits, None).await
}
/// Internal entry that accepts an optional parent-shared semaphore so a
/// `fanout_multi` invocation can prevent its sub-targets' fan-outs from
/// each opening their own concurrency budget. When `shared_sem` is `None`
/// (top-level call), each fan-out function creates its own.
async fn run_with_limits_shared(
module: Arc<dyn Module>,
target: Target,
options: ModuleOptions,
verbose: bool,
limits: SchedulerLimits,
shared_sem: Option<Arc<Semaphore>>,
) -> Result<ModuleOutcome> {
let cancel = crate::context::cancellation_token()
.unwrap_or_default();
let tenant_id = crate::context::current_tenant_id();
let module_path = current_module_path(module.as_ref());
// Refresh the rate limiter from the current options at the start of each
// top-level scan (not for `fanout_multi` sub-targets, which pass a shared
// semaphore) so a `setg target_rps` / `setg module_rps` changed since the
// last run actually takes effect instead of being pinned to the values the
// process-global limiter was first built with.
if shared_sem.is_none() {
crate::rate_limit::shared().reload().await;
}
// Pre-flight: validate options once before fan-out so the operator
// sees one error instead of N identical errors across N hosts.
let mut precheck_ctx = ModuleCtx::new(target.clone());
precheck_ctx.options = options.clone();
precheck_ctx.cancel = cancel.clone();
precheck_ctx.tenant_id = tenant_id.clone();
precheck_ctx.verbose = verbose;
precheck_ctx.module_path = module_path.clone();
if let Err(e) = module.pre_check(&precheck_ctx).await {
crate::meprintln!("[!] Pre-flight check failed: {e:#}");
return Err(e);
}
drop(precheck_ctx);
// If no explicit `setg port` was given, fall back to the module's
// declared default_port so mass-scan pre-checks can skip hosts that
// don't have the service port open.
let limits = {
let mut l = limits;
if l.precheck_port.is_none() {
l.precheck_port = module.info().default_port;
}
// Full-sweep cap consistency: a literal `0.0.0.0/0` / `random` target
// entered directly (e.g. `use <mod>; run`) never passes through the
// shell's `setg target` handler, so without this it would silently fall
// back to the scheduler default of 10_000 — while `setg target 0.0.0.0`
// auto-bumps to the full public-IPv4 count. Normalise both paths: when
// the operator has NOT explicitly set `max_random_hosts`, a full sweep
// means every reachable public host. An explicit `setg max_random_hosts`
// is always honoured. The advisory + confirmation gate still runs.
if matches!(target, Target::Random) {
let operator_set = crate::tenant::resolve()
.global_options()
.try_get("max_random_hosts")
.is_some();
if !operator_set {
l.max_random_hosts = crate::utils::cyclic::total_public_ipv4_count() as usize;
}
}
l
};
// Mass-scan fan-out is universal: every module gets fanned out per host
// for Cidr/Multi/File/Random, sees `Target::Single` inside `run()`.
let outcome = match target.clone() {
Target::Single(_) => fanout_single(FanoutParams { module: module.clone(), target, options, cancel, tenant_id: tenant_id.clone(), limits, module_path: module_path.clone() }, verbose).await,
Target::Cidr(_) => fanout_cidr(FanoutParams { module: module.clone(), target, options, cancel, tenant_id: tenant_id.clone(), limits, module_path: module_path.clone() }, shared_sem.clone()).await,
Target::File(_) => fanout_file(FanoutParams { module: module.clone(), target, options, cancel, tenant_id: tenant_id.clone(), limits, module_path: module_path.clone() }, shared_sem.clone()).await,
Target::Multi(_) => fanout_multi(module.clone(), target, options, cancel, tenant_id.clone(), verbose, limits).await,
// `0.0.0.0/0` / `random` honour `setg scan_order sequential` so an
// operator can flip the full-internet sweep between random and in-order.
Target::Random => {
if scan_order_is_sequential() {
fanout_sequential(crate::module::FIRST_PUBLIC_IPV4, module.clone(), options, cancel, tenant_id.clone(), limits, module_path.clone(), shared_sem.clone()).await
} else {
fanout_random(module.clone(), options, cancel, tenant_id.clone(), limits, module_path.clone(), shared_sem.clone()).await
}
}
Target::Sequential(start) => fanout_sequential(start, module.clone(), options, cancel, tenant_id.clone(), limits, module_path.clone(), shared_sem.clone()).await,
};
// Cleanup runs whether the fan-out succeeded or failed so modules
// can release resources (connections, temp files) unconditionally.
{
let fallback = ModuleOutcome::fail();
let out = match outcome {
Ok(ref o) => o,
Err(ref e) => {
crate::meprintln!("[!] Fan-out failed: {e:#}");
&fallback
}
};
let mut cleanup_ctx = ModuleCtx::new(Target::Single(String::new()));
cleanup_ctx.tenant_id = tenant_id;
cleanup_ctx.verbose = verbose;
cleanup_ctx.module_path = module_path;
if let Err(e) = module.cleanup(&cleanup_ctx, out).await {
crate::meprintln!("[!] Cleanup hook failed: {e:#}");
}
}
outcome
}
/// Resolve a `&dyn Module` back to its registered `category/name` path.
/// Used by the scheduler to populate `ModuleCtx::module_path` so the rate
/// limiter and finding-routing know which entry produced a given outcome.
fn current_module_path(module: &dyn Module) -> String {
let info_name = module.info().name;
// Pass 1: compare display name without instantiating every module.
// `entry.name` is the registry key (e.g. "ssh/known_vuln"), while
// `info_name` is the human-readable title. We need to compare
// info().name from the factory, but avoid instantiating all modules.
// Instead, compare the passed module's info().name against each
// factory's info().name — break on first match to avoid O(N) allocs.
for entry in crate::module::registered() {
if (entry.factory)().info().name == info_name {
return format!("{}/{}", entry.category.as_str(), entry.name);
}
}
info_name
}
/// One initial attempt plus a single retry per host. Bounded so a transient
/// blip is absorbed without doubling the wall-clock of a full sweep.
const MAX_HOST_ATTEMPTS: usize = 2;
/// Classify an error as a transient network condition worth one retry. A closed
/// port (`ConnectionRefused`) or a non-IO module error (e.g. "target not
/// affected") is terminal, so a mass scan never wastes a retry on a host that
/// simply does not run the service.
fn is_transient_error(err: &anyhow::Error) -> bool {
for cause in err.chain() {
if let Some(io) = cause.downcast_ref::<std::io::Error>() {
use std::io::ErrorKind;
if matches!(
io.kind(),
ErrorKind::TimedOut
| ErrorKind::ConnectionReset
| ErrorKind::ConnectionAborted
| ErrorKind::BrokenPipe
| ErrorKind::UnexpectedEof
) {
return true;
}
}
}
// The per-host deadline wrapper produces an anyhow error (not an io::Error)
// whose message starts with "timed out".
err.to_string().contains("timed out")
}
/// Run a module against one host with bounded retry on transient failures, then
/// return the outcome for the caller to record. A transient error is retried up
/// to `MAX_HOST_ATTEMPTS` with a short backoff; any other error returns
/// immediately. Either way a single host never aborts the surrounding mass-scan
/// loop — the caller records the outcome and continues to the next host.
async fn run_host_with_retry(
module: &Arc<dyn Module>,
ctx: &ModuleCtx,
per_host_timeout: Option<Duration>,
) -> Result<ModuleOutcome> {
let mut attempt = 0usize;
loop {
attempt += 1;
let res = match per_host_timeout {
Some(d) => match tokio::time::timeout(d, module.run(ctx)).await {
Ok(r) => r,
Err(e) => Err(anyhow::anyhow!("timed out: {e}")),
},
None => module.run(ctx).await,
};
match res {
Ok(outcome) => return Ok(outcome),
Err(e) => {
if attempt < MAX_HOST_ATTEMPTS && !ctx.is_cancelled() && is_transient_error(&e) {
tokio::time::sleep(Duration::from_millis(200)).await;
continue;
}
return Err(e);
}
}
}
}
// ============================================================
// FAN-OUT
// ============================================================
/// Shared parameters for all fanout dispatch functions.
struct FanoutParams {
module: Arc<dyn Module>,
target: Target,
options: ModuleOptions,
cancel: tokio_util::sync::CancellationToken,
tenant_id: Option<String>,
limits: SchedulerLimits,
module_path: String,
}
async fn fanout_single(
params: FanoutParams,
verbose: bool,
) -> Result<ModuleOutcome> {
let FanoutParams { module, target, options, cancel, tenant_id, limits, module_path } = params;
let host = target.as_single().unwrap_or("").to_string();
if limits.honeypot_detection
&& crate::utils::network::quick_honeypot_check(&host).await
{
crate::mprintln!(
"{}",
format!(
"[!] Target {} appears to be a honeypot (11+ common ports open) — skipping",
host
)
.red()
.bold()
);
return Ok(ModuleOutcome::ok());
}
let mut ctx = ModuleCtx::new(target);
ctx.options = options;
ctx.cancel = cancel;
ctx.tenant_id = tenant_id;
ctx.verbose = verbose;
ctx.module_path = module_path;
// The per-target deadline is a MASS-SCAN safeguard: when fanning out across
// many hosts, one slow/hung host must not starve the scan. For a SINGLE,
// deliberately-chosen target it is actively harmful:
// * interactive modules (wpair's REPL) own their own session lifetime, and
// * any module that prompts the operator inside run() (e.g. asyncssh's
// "Username to impersonate") gets killed the moment the human takes
// longer than the deadline to type. Worse, that prompt is a blocking
// `spawn_blocking` stdin read which cannot be cancelled — killing the
// run orphans the reader thread, which then races the shell for the next
// line (the "lag after error" + corrupted REPL input).
//
// Enforce the deadline only when there is NO human at the console to answer
// prompts or hit Ctrl-C — i.e. an API/MCP/background run (api_mode), or a
// scripted/piped CLI run whose stdin is not a terminal. In those contexts
// cfg_prompt_* resolves from options/defaults (it never blocks on a real
// keyboard) and the run must stay bounded so it can't hang a request or a
// script. An interactive console (stdin is a TTY) is exempt: the operator
// chose this single target, can watch it, and can interrupt it — and a
// prompt blocking on the keyboard must never count against a deadline.
// Interactive modules (wpair's REPL) are always exempt. Mass-scan fan-out
// paths keep their own per-host timeouts (fanout_cidr/_file/_random).
let no_console_operator =
crate::config::get_module_config().api_mode || !std::io::stdin().is_terminal();
let enforce_deadline = !module.capabilities().interactive && no_console_operator;
let outcome = if enforce_deadline {
tokio::time::timeout(Duration::from_secs(limits.timeout_secs), module.run(&ctx))
.await
.map_err(|e| {
anyhow::anyhow!("Module timed out after {}s: {e}", limits.timeout_secs)
})??
} else {
module.run(&ctx).await?
};
// Publish single-target progress to the job's scan counters (if any).
if let Some(c) = crate::context::scan_counters() {
c.total.store(1, Ordering::Relaxed);
if outcome.success {
c.succeeded.store(1, Ordering::Relaxed);
} else {
c.failed.store(1, Ordering::Relaxed);
}
}
route_findings(&outcome, module.as_ref()).await;
Ok(outcome)
}
async fn fanout_cidr(
params: FanoutParams,
shared_sem: Option<Arc<Semaphore>>,
) -> Result<ModuleOutcome> {
let FanoutParams { module, target, options, cancel, tenant_id, limits, module_path } = params;
let cidr = match &target {
Target::Cidr(s) => s.clone(),
// Reachable only via an internal dispatch bug; surface it as a clean
// error rather than panicking the process (and it clears the no-panic gate).
_ => anyhow::bail!("internal error: fanout_cidr called with non-Cidr target"),
};
let network = crate::utils::parse_subnet(&cidr)?;
let host_count = crate::utils::subnet_host_count(&network);
if host_count <= 1 {
let single = Target::Single(network.network().to_string());
return Box::pin(run_with_limits_shared(
module, single, options, false, limits, shared_sem,
))
.await;
}
if network.is_ipv6() && host_count > limits.ipv6_max_hosts {
anyhow::bail!(
"IPv6 subnet {} expands to {} hosts — too wide. Use a narrower prefix.",
network,
host_count
);
}
if host_count > limits.warn_threshold {
let est_secs = (host_count / (limits.concurrency.max(1) as u128))
.saturating_mul(limits.timeout_secs as u128);
let est = if est_secs > 86_400 {
format!("{:.1} days", est_secs as f64 / 86_400.0)
} else if est_secs > 3600 {
format!("{:.1} hours", est_secs as f64 / 3600.0)
} else {
format!("{} minutes", est_secs / 60)
};
crate::mprintln!(
"{}",
format!(
"[!] Large scan: {} expands to {} hosts (worst-case ~{} at concurrency {})",
network, host_count, est, limits.concurrency
)
.yellow()
.bold()
);
let cfg = crate::config::get_module_config();
if !cfg.api_mode && !crate::utils::is_batch_mode() {
let confirmed = crate::utils::prompt_yes_no(
&format!("Proceed with scanning all {} hosts?", host_count),
false,
)
.await?;
if !confirmed {
anyhow::bail!("CIDR scan of {} aborted by user.", network);
}
}
}
// --- Pre-batch interactive prompt phase ---
let cfg = crate::config::get_module_config();
if !cfg.api_mode {
pre_batch_prompt(&module, &options, cancel.clone(), tenant_id.clone(), &module_path).await?;
}
let batch_guard = crate::context::enter_batch_mode();
let stats = Arc::new(ScanStats::default());
// When the parent (e.g. `fanout_multi`) supplies a shared semaphore,
// use it so cross-target runs share one concurrency budget; otherwise
// create a fresh one sized to the configured concurrency.
let sem = shared_sem
.unwrap_or_else(|| Arc::new(Semaphore::new(limits.concurrency)));
let prompt_cache = crate::context::new_prompt_cache();
let checkpoint = open_checkpoint(&module_path, &cidr).await;
// Optional: hand the CIDR to masscan/zmap first, then run the module
// only against live hosts. Speedup is dramatic on sparse ranges.
let prescan_choice = crate::prescan::Prescan::from_global_options();
let live_hosts: Option<Vec<String>> = if prescan_choice.is_enabled() {
match crate::prescan::discover_live(&network, prescan_choice).await {
Ok(ips) if !ips.is_empty() => Some(ips),
Ok(_) => None,
Err(e) => {
crate::meprintln!(
"[!] prescan failed: {e:#}. Falling back to per-IP fan-out."
);
None
}
}
} else {
None
};
let effective_count: u128 = match &live_hosts {
Some(v) => v.len() as u128,
None => host_count,
};
let progress_step = progress_step_for(effective_count);
crate::mprintln!(
"{}",
format!(
"[*] Subnet: {} ({} {}) — running '{}' with concurrency {}",
network,
effective_count,
if live_hosts.is_some() { "live hosts via prescan" } else { "hosts" },
module.info().name,
limits.concurrency
)
.cyan()
);
let ip_iter: Box<dyn Iterator<Item = std::net::IpAddr> + Send> = match live_hosts {
Some(ips) => Box::new(ips.into_iter().filter_map(|s| s.parse().ok())),
None => Box::new(network.iter()),
};
// Per-fanout JoinSet — tracks completion of *this* fan-out's spawns
// even when the semaphore is shared with a sibling sub-target via
// `fanout_multi`. Without this we couldn't tell when our own slice
// of the work was done.
let mut joinset = tokio::task::JoinSet::<()>::new();
// Honor the operator's exclusion list and the service-port precheck on CIDR
// scans too (previously only `fanout_random` did), so `setg exclusions ...`
// actually filters a subnet sweep and a sparse range doesn't run the module
// against every host whose service port is closed.
let exclusions = crate::exclusions::shared();
let precheck_port = limits.precheck_port;
let honeypot = limits.honeypot_detection;
for ip in ip_iter {
if cancel.is_cancelled() || stats.abort.load(Ordering::Relaxed) {
break;
}
let ip_str = ip.to_string();
// Count every host the fan-out looks at as "considered" (even when
// skipped here in the producer before spawning), so progress's
// examined/total denominator stays accurate under exclusions/resume.
if exclusions.contains(ip) {
stats.considered.fetch_add(1, Ordering::Relaxed);
stats.skipped.fetch_add(1, Ordering::Relaxed);
continue;
}
// Skip already-processed targets when resuming.
if let Some(cp) = &checkpoint
&& cp.already_processed(&ip_str).await {
stats.considered.fetch_add(1, Ordering::Relaxed);
stats.skipped.fetch_add(1, Ordering::Relaxed);
continue;
}
// Race the permit acquisition against cancellation: when the semaphore
// is saturated this await can block for a long time, and without the
// race a Ctrl-C would not stop the dispatcher from enqueuing more hosts
// until a permit happened to free up. `biased` checks cancel first.
let permit = tokio::select! {
biased;
_ = cancel.cancelled() => break,
p = sem.clone().acquire_owned() => p.context("Semaphore closed")?,
};
let module_clone = module.clone();
let opts = options.clone();
let cache = prompt_cache.clone();
let cancel_clone = cancel.clone();
let tenant = tenant_id.clone();
let stats_clone = stats.clone();
let mp = module_path.clone();
let cp_clone = checkpoint.clone();
joinset.spawn(async move {
// Load-bearing: holds the owned semaphore permit for the task's
// whole lifetime so concurrency stays bounded; the slot is freed
// when this task ends. This is NOT a `let _ =` discard.
let _permit = permit;
let ip_str = ip.to_string();
if let Some(n) = stats_clone.tick(progress_step) {
crate::mprintln!("{}", progress_line(n, effective_count, &stats_clone.snapshot()));
}
// Service-port + honeypot precheck (skips hosts that don't have the
// module's port open), consistent with the random fan-out.
if !crate::utils::network::mass_scan_precheck(ip, precheck_port, honeypot).await {
stats_clone.skipped.fetch_add(1, Ordering::Relaxed);
record_checkpoint(&cp_clone, &ip_str).await;
return;
}
stats_clone.processed.fetch_add(1, Ordering::Relaxed);
let mut ctx = ModuleCtx::new(Target::Single(ip_str.clone()));
ctx.options = opts;
ctx.cancel = cancel_clone;
ctx.tenant_id = tenant;
ctx.batch_mode = true;
ctx.prompt_cache = Some(cache);
ctx.module_path = mp;
let outcome = run_host_with_retry(
&module_clone,
&ctx,
Some(Duration::from_secs(limits.timeout_secs)),
)
.await;
stats_clone.record(outcome);
record_checkpoint(&cp_clone, &ip_str).await;
});
}
drain_joinset(&mut joinset).await;
let completed_cleanly = !cancel.is_cancelled() && !stats.abort.load(Ordering::Relaxed);
finalize_checkpoint(&checkpoint, completed_cleanly).await;
let outcome = finalize(&format!("Subnet Scan ({})", network), &stats, effective_count as usize, completed_cleanly);
route_findings(&outcome, module.as_ref()).await;
drop(batch_guard);
Ok(outcome)
}
async fn fanout_file(
params: FanoutParams,
shared_sem: Option<Arc<Semaphore>>,
) -> Result<ModuleOutcome> {
let FanoutParams { module, target, options, cancel, tenant_id, limits, module_path } = params;
let path = match &target {
Target::File(p) => p.clone(),
_ => anyhow::bail!("internal error: fanout_file called with non-File target"),
};
let content = crate::utils::safe_read_to_string_async(
path.to_str().unwrap_or(""),
None,
)
.await
.with_context(|| format!("Failed to read target file '{}'", path.display()))?;
let targets: Vec<String> = content
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty() && !s.starts_with('#'))
.collect();
let count = targets.len();
crate::mprintln!(
"{}",
format!(
"[*] File target list: {} hosts from '{}' — running '{}'",
count,
path.display(),
module.info().name
)
.cyan()
.bold()
);
// --- Pre-batch interactive prompt phase ---
let cfg = crate::config::get_module_config();
if !cfg.api_mode {
pre_batch_prompt(&module, &options, cancel.clone(), tenant_id.clone(), &module_path).await?;
}
let batch_guard = crate::context::enter_batch_mode();
let stats = Arc::new(ScanStats::default());
let sem = shared_sem
.unwrap_or_else(|| Arc::new(Semaphore::new(limits.concurrency)));
let prompt_cache = crate::context::new_prompt_cache();
// Checkpoint key includes the file path so different host lists
// checkpoint independently.
let checkpoint = open_checkpoint(&module_path, &path.to_string_lossy()).await;
let progress_step = progress_step_for(count as u128);
let mut joinset = tokio::task::JoinSet::<()>::new();
// Apply exclusions + the service-port precheck to file scans too. Entries
// that are IP literals get the same treatment as random/CIDR scans; hostname
// entries can't be cheaply IP-filtered, so they fall back to the honeypot
// check only (resolving every name up front would add latency).
let exclusions = crate::exclusions::shared();
let precheck_port = limits.precheck_port;
let honeypot = limits.honeypot_detection;
for host in targets {
if cancel.is_cancelled() {
break;
}
// Count producer-side skips as considered too (accurate progress).
if let Ok(ip) = host.parse::<std::net::IpAddr>()
&& exclusions.contains(ip) {
stats.considered.fetch_add(1, Ordering::Relaxed);
stats.skipped.fetch_add(1, Ordering::Relaxed);
continue;
}
if let Some(cp) = checkpoint.as_ref()
&& cp.already_processed(&host).await {
stats.considered.fetch_add(1, Ordering::Relaxed);
stats.skipped.fetch_add(1, Ordering::Relaxed);
continue;
}
// Race the permit acquisition against cancellation: when the semaphore
// is saturated this await can block for a long time, and without the
// race a Ctrl-C would not stop the dispatcher from enqueuing more hosts
// until a permit happened to free up. `biased` checks cancel first.
let permit = tokio::select! {
biased;
_ = cancel.cancelled() => break,
p = sem.clone().acquire_owned() => p.context("Semaphore closed")?,
};
let module_clone = module.clone();
let opts = options.clone();
let cache = prompt_cache.clone();
let cancel_clone = cancel.clone();
let tenant = tenant_id.clone();
let stats_clone = stats.clone();
let mp = module_path.clone();
let cp_clone = checkpoint.clone();
joinset.spawn(async move {
// Load-bearing: holds the owned semaphore permit for the task's
// whole lifetime so concurrency stays bounded; the slot is freed
// when this task ends. This is NOT a `let _ =` discard.
let _permit = permit;
if let Some(n) = stats_clone.tick(progress_step) {
crate::mprintln!("{}", progress_line(n, count as u128, &stats_clone.snapshot()));
}
// IP-literal entries get the full service-port + honeypot precheck;
// hostname entries keep the honeypot-only check.
let skip = match host.parse::<std::net::IpAddr>() {
Ok(ip) => !crate::utils::network::mass_scan_precheck(ip, precheck_port, honeypot).await,
Err(e) => {
tracing::trace!("'{host}' is not an IP literal ({e}); honeypot-only precheck");
honeypot && crate::utils::network::quick_honeypot_check(&host).await
}
};
if skip {
stats_clone.skipped.fetch_add(1, Ordering::Relaxed);
record_checkpoint(&cp_clone, &host).await;
return;
}
stats_clone.processed.fetch_add(1, Ordering::Relaxed);
let mut ctx = ModuleCtx::new(Target::Single(host.clone()));
ctx.options = opts;
ctx.cancel = cancel_clone;
ctx.tenant_id = tenant;
ctx.batch_mode = true;
ctx.prompt_cache = Some(cache);
ctx.module_path = mp;
let outcome = run_host_with_retry(
&module_clone,
&ctx,
Some(Duration::from_secs(limits.timeout_secs)),
)
.await;
stats_clone.record(outcome);
record_checkpoint(&cp_clone, &host).await;
});
}
drain_joinset(&mut joinset).await;
let completed_cleanly = !cancel.is_cancelled();
finalize_checkpoint(&checkpoint, completed_cleanly).await;
let outcome = finalize("File Target Scan", &stats, count, completed_cleanly);
route_findings(&outcome, module.as_ref()).await;
drop(batch_guard);
Ok(outcome)
}
async fn fanout_multi(
module: Arc<dyn Module>,
target: Target,
options: ModuleOptions,
cancel: tokio_util::sync::CancellationToken,
tenant_id: Option<String>,
verbose: bool,
limits: SchedulerLimits,
) -> Result<ModuleOutcome> {
let parts = match target {
Target::Multi(p) => p,
_ => anyhow::bail!("internal error: fanout_multi called with non-Multi target"),
};
let total = parts.len();
if let Some(ref tid) = tenant_id {
tracing::debug!("fanout_multi: tenant_id={}, targets={}", tid, total);
}
crate::mprintln!(
"{}",
format!(
"[*] Multi-target detected: {} targets — running '{}' against each",
total,
module.info().name
)
.cyan()
);
// Single shared semaphore across every sub-target's fan-out so the
// total in-flight task count never exceeds `limits.concurrency`,
// regardless of how many CIDR/file/random sub-targets run.
let shared_sem = Arc::new(Semaphore::new(limits.concurrency));
let mut combined = ModuleOutcome::ok();
for (i, sub) in parts.into_iter().enumerate() {
if cancel.is_cancelled() {
break;
}
crate::mprintln!(
"\n{}",
format!("[*] === Target {}/{} ===", i + 1, total).cyan().bold()
);
match Box::pin(run_with_limits_shared(
module.clone(),
sub,
options.clone(),
verbose,
limits,
Some(shared_sem.clone()),
))
.await
{
Ok(out) => {
if !out.success {
combined.success = false;
}
combined.findings.extend(out.findings);
}
Err(e) => {
combined.success = false;
crate::meprintln!("{}", format!("[!] Sub-target failed: {:?}", e).red());
}
}
}
Ok(combined)
}
async fn fanout_random(
module: Arc<dyn Module>,
options: ModuleOptions,
cancel: tokio_util::sync::CancellationToken,
tenant_id: Option<String>,
limits: SchedulerLimits,
module_path: String,
shared_sem: Option<Arc<Semaphore>>,
) -> Result<ModuleOutcome> {
// Full-internet sweep advisory + interactive confirmation FIRST. Declining
// must abort before anything is touched — including the prompt-harvest
// dry-run below, which actually runs the module against a placeholder host.
if !full_sweep_advisory_and_confirm(
&module,
&limits,
Some(limits.max_random_hosts),
"Random mass scan",
)
.await?
{
return Ok(ModuleOutcome::ok());
}
// --- Pre-batch interactive prompt phase ---
// Run module prompts ONCE before batch mode so the user can configure
// interactively. Answers are cached and reused for all targets.
let cfg = crate::config::get_module_config();
if !cfg.api_mode {
pre_batch_prompt(&module, &options, cancel.clone(), tenant_id.clone(), &module_path).await?;
}
let batch_guard = crate::context::enter_batch_mode();
let stats = Arc::new(ScanStats::default());
let sem = shared_sem
.unwrap_or_else(|| Arc::new(Semaphore::new(limits.concurrency)));
let prompt_cache = crate::context::new_prompt_cache();
// Pluggable per-tenant exclusion list. Operators control via
// `setg exclusions ...`; falls back to bogons + RFC1918 + Cloudflare + DNS.
let exclusion_set = crate::exclusions::shared();
let exclusions = Arc::new(exclusion_set.networks().to_vec());
let module_err = Arc::new(AtomicUsize::new(0));
// Random scans checkpoint by IP so a kill-then-restart skips the IPs
// that already ran. Useful when the operator sets `max_random_hosts`
// high and aborts mid-scan.
let checkpoint = open_checkpoint(&module_path, "random").await;
let mut joinset = tokio::task::JoinSet::<()>::new();
crate::mprintln!(
"{}",
format!(
"[*] Random mass scan — running '{}' against random public IPs (Ctrl+C to stop)",
module.info().name
)
.cyan()
.bold()
);
crate::mprintln!(
"{}",
format!(
"[*] Will scan up to {} hosts with concurrency {} (ZMap-style stateless permutation — no repeats)",
limits.max_random_hosts, limits.concurrency
)
.cyan()
);
let progress_step = progress_step_for(limits.max_random_hosts as u128);
// ZMap-style stateless permutation of the IPv4 space: every address is
// visited at most once with O(1) memory and no dedup table (see
// `crate::utils::cyclic`). This replaces the old random-sample + `HashSet`,
// which grew unboundedly and — as coverage rose — wasted an ever-larger
// share of samples re-drawing addresses it had already seen.
let mut cyclic = crate::utils::cyclic::CyclicIp::random()?;
let mut dispatched: usize = 0;
while dispatched < limits.max_random_hosts {
if cancel.is_cancelled() || stats.abort.load(Ordering::Relaxed) {
break;
}
// Pull the next real, non-excluded address from the permutation.
// Reserved ranges and operator exclusions are skipped without counting
// against the host budget (they were never valid targets). `None` means
// the entire address space has been enumerated.
let ip = 'pick: loop {
match cyclic.next() {
Some(addr) => {
if crate::utils::cyclic::is_reserved_ipv4(addr) {
continue;
}
let ip = std::net::IpAddr::V4(addr);
if exclusions.iter().any(|net| net.contains(ip)) {
continue;
}
break 'pick Some(ip);
}
None => break 'pick None,
}
};
let Some(ip) = ip else { break };
dispatched += 1;
let ip_str = ip.to_string();
if let Some(cp) = checkpoint.as_ref()
&& cp.already_processed(&ip_str).await {
stats.skipped.fetch_add(1, Ordering::Relaxed);
continue;
}
// Race the permit acquisition against cancellation: when the semaphore
// is saturated this await can block for a long time, and without the
// race a Ctrl-C would not stop the dispatcher from enqueuing more hosts
// until a permit happened to free up. `biased` checks cancel first.
let permit = tokio::select! {
biased;
_ = cancel.cancelled() => break,
p = sem.clone().acquire_owned() => p.context("Semaphore closed")?,
};
let module_clone = module.clone();
let opts = options.clone();
let cache = prompt_cache.clone();
let cancel_clone = cancel.clone();
let tenant = tenant_id.clone();
let stats_clone = stats.clone();
let module_err_clone = module_err.clone();
let port = limits.precheck_port;
let honeypot = limits.honeypot_detection;
let mp = module_path.clone();
let cp_clone = checkpoint.clone();
joinset.spawn(async move {
// Load-bearing: holds the owned semaphore permit for the task's
// whole lifetime so concurrency stays bounded; the slot is freed
// when this task ends. This is NOT a `let _ =` discard.
let _permit = permit;
// Tick BEFORE the precheck so progress advances even when (as is
// typical for random ranges) almost every host fails the port
// precheck and is skipped. Previously progress only moved on
// hosts that passed the precheck, so a sparse scan looked frozen.
if let Some(n) = stats_clone.tick(progress_step) {
crate::mprintln!(
"{}",
progress_line(n, limits.max_random_hosts as u128, &stats_clone.snapshot())
);
}
if !crate::utils::network::mass_scan_precheck(ip, port, honeypot).await {
stats_clone.skipped.fetch_add(1, Ordering::Relaxed);
record_checkpoint(&cp_clone, &ip.to_string()).await;
return;
}
stats_clone.processed.fetch_add(1, Ordering::Relaxed);
let mut ctx = ModuleCtx::new(Target::Single(ip.to_string()));
ctx.options = opts;
ctx.cancel = cancel_clone;
ctx.tenant_id = tenant;
ctx.batch_mode = true;
ctx.prompt_cache = Some(cache);
ctx.module_path = mp;
let outcome = run_host_with_retry(
&module_clone,
&ctx,
Some(Duration::from_secs(limits.timeout_secs)),
)
.await;
let was_err = stats_clone.record(outcome);
record_checkpoint(&cp_clone, &ip.to_string()).await;
if was_err {
let n = module_err_clone.fetch_add(1, Ordering::Relaxed) + 1;
let ok_so_far = stats_clone.success.load(Ordering::Relaxed);
// Warn once if the run looks misconfigured, but keep going:
// transient failures are already retried, and the operator asked
// for error -> retry -> continue rather than an automatic abort.
if n == 10 && ok_so_far == 0 {
crate::meprintln!(
"{}",
"[!] First 10 dispatches errored with no successes — continuing anyway (check module/target config if this persists; Ctrl+C to stop)."
.yellow()
);
}
}
});
}
drain_joinset(&mut joinset).await;
let completed_cleanly = !cancel.is_cancelled() && !stats.abort.load(Ordering::Relaxed);
finalize_checkpoint(&checkpoint, completed_cleanly).await;
// Denominator is the host budget, not the post-precheck count, so
// "examined / total" reflects how far through the random sweep we got.
let outcome = finalize("Random Mass Scan", &stats, limits.max_random_hosts, completed_cleanly);
route_findings(&outcome, module.as_ref()).await;
drop(batch_guard);
Ok(outcome)
}
/// `true` when the operator selected sequential mass-scan order via
/// `setg scan_order sequential` (also accepts `seq`).
fn scan_order_is_sequential() -> bool {
crate::tenant::resolve()
.global_options()
.try_get("scan_order")
.map(|v| {
let v = v.trim().to_ascii_lowercase();
v == "sequential" || v == "seq"
})
.unwrap_or(false)
}
/// Sequential public-IPv4 sweep from `start` (clamped to the first public
/// address) up to the last public address (223.255.255.255), in order. Honors
/// the exclusion list, the service-port precheck, honeypot detection, and a
/// high-water-mark checkpoint so a killed scan resumes from where it left off.
/// Unbounded by default; capped only when the operator sets `max_random_hosts`.
#[allow(clippy::too_many_arguments)]
async fn fanout_sequential(
start: u32,
module: Arc<dyn Module>,
options: ModuleOptions,
cancel: tokio_util::sync::CancellationToken,
tenant_id: Option<String>,
limits: SchedulerLimits,
module_path: String,
shared_sem: Option<Arc<Semaphore>>,
) -> Result<ModuleOutcome> {
const LAST_PUBLIC: u32 = 0xDFFF_FFFF; // 223.255.255.255 (end of Class A-C)
let cfg = crate::config::get_module_config();
// Optional operator cap. Unbounded unless `max_random_hosts` is explicitly set.
let cap: Option<usize> = match crate::tenant::resolve()
.global_options()
.try_get("max_random_hosts")
{
Some(v) => match v.trim().parse::<usize>() {
Ok(n) => Some(n),
Err(e) => {
crate::meprintln!(
"[!] invalid max_random_hosts '{}' ({e}) — running unbounded.",
v.trim()
);
None
}
},
None => None,
};
// Full-internet sweep advisory + interactive confirmation FIRST. Declining
// must abort before anything is touched (the resume marker, and the
// prompt-harvest dry-run below which runs the module against a placeholder).
if !full_sweep_advisory_and_confirm(&module, &limits, cap, "Sequential mass scan").await? {
return Ok(ModuleOutcome::ok());
}
// Pre-batch interactive prompt phase (after confirmation).
if !cfg.api_mode {
pre_batch_prompt(&module, &options, cancel.clone(), tenant_id.clone(), &module_path).await?;
}
let batch_guard = crate::context::enter_batch_mode();
let stats = Arc::new(ScanStats::default());
let sem = shared_sem.unwrap_or_else(|| Arc::new(Semaphore::new(limits.concurrency)));
let prompt_cache = crate::context::new_prompt_cache();
let exclusions = crate::exclusions::shared();
let module_err = Arc::new(AtomicUsize::new(0));
// High-water-mark resume: the per-IP set used by random/CIDR does not scale
// to billions of addresses, so sequential stores the last dispatched IP and
// resumes from `hi + 1`.
let scan_id = crate::checkpoint::auto_scan_id(&module_path, "sequential");
let mut start_ip = start.max(crate::module::FIRST_PUBLIC_IPV4);
if let Some(hi) = crate::checkpoint::read_seq_marker(&scan_id) {
let resume = hi.saturating_add(1);
if resume > start_ip {
crate::mprintln!(
"{}",
format!(
"[*] Resuming sequential scan from {}.",
std::net::Ipv4Addr::from(resume)
)
.green()
);
start_ip = resume;
}
}
let range_total: u128 = if start_ip > LAST_PUBLIC {
0
} else {
(LAST_PUBLIC - start_ip) as u128 + 1
};
let total: u128 = match cap {
Some(c) => (c as u128).min(range_total),
None => range_total,
};
crate::mprintln!(
"{}",
format!(
"[*] Sequential mass scan — running '{}' from {} to {} (Ctrl+C to stop)",
module.info().name,
std::net::Ipv4Addr::from(start_ip),
std::net::Ipv4Addr::from(LAST_PUBLIC)
)
.cyan()
.bold()
);
match cap {
Some(c) => crate::mprintln!(
"{}",
format!("[*] Capped at {c} hosts (max_random_hosts), concurrency {}", limits.concurrency).cyan()
),
None => crate::mprintln!(
"{}",
format!("[*] Unbounded sweep, concurrency {}", limits.concurrency).cyan()
),
}
let progress_step = progress_step_for(total);
let mut joinset = tokio::task::JoinSet::<()>::new();
let mut dispatched: usize = 0;
let mut ip = start_ip;
let mut last_dispatched = start_ip;
loop {
if ip > LAST_PUBLIC || cancel.is_cancelled() || stats.abort.load(Ordering::Relaxed) {
break;
}
if let Some(c) = cap
&& dispatched >= c
{
break;
}
let addr = std::net::Ipv4Addr::from(ip);
if exclusions.contains(std::net::IpAddr::V4(addr)) {
match ip.checked_add(1) {
Some(n) => {
ip = n;
continue;
}
None => break,
}
}
// Race permit acquisition against cancellation (see `fanout_random`).
let permit = tokio::select! {
biased;
_ = cancel.cancelled() => break,
p = sem.clone().acquire_owned() => p.context("Semaphore closed")?,
};
let module_clone = module.clone();
let opts = options.clone();
let cache = prompt_cache.clone();
let cancel_clone = cancel.clone();
let tenant = tenant_id.clone();
let stats_clone = stats.clone();
let module_err_clone = module_err.clone();
let port = limits.precheck_port;
let honeypot = limits.honeypot_detection;
let mp = module_path.clone();
let timeout_secs = limits.timeout_secs;
joinset.spawn(async move {
// Load-bearing: holds the permit for the task's lifetime.
let _permit = permit;
if let Some(n) = stats_clone.tick(progress_step) {
crate::mprintln!("{}", progress_line(n, total, &stats_clone.snapshot()));
}
if !crate::utils::network::mass_scan_precheck(std::net::IpAddr::V4(addr), port, honeypot)
.await
{
stats_clone.skipped.fetch_add(1, Ordering::Relaxed);
return;
}
stats_clone.processed.fetch_add(1, Ordering::Relaxed);
let mut ctx = ModuleCtx::new(Target::Single(addr.to_string()));
ctx.options = opts;
ctx.cancel = cancel_clone;
ctx.tenant_id = tenant;
ctx.batch_mode = true;
ctx.prompt_cache = Some(cache);
ctx.module_path = mp;
let outcome = run_host_with_retry(
&module_clone,
&ctx,
Some(Duration::from_secs(timeout_secs)),
)
.await;
let was_err = stats_clone.record(outcome);
if was_err {
let n = module_err_clone.fetch_add(1, Ordering::Relaxed) + 1;
let ok_so_far = stats_clone.success.load(Ordering::Relaxed);
// Warn once if the run looks misconfigured, but keep going:
// transient failures are already retried, and the operator asked
// for error -> retry -> continue rather than an automatic abort.
if n == 10 && ok_so_far == 0 {
crate::meprintln!(
"{}",
"[!] First 10 dispatches errored with no successes — continuing anyway (check module/target config if this persists; Ctrl+C to stop)."
.yellow()
);
}
}
});
dispatched += 1;
last_dispatched = ip;
// Persist the high-water mark periodically (cheap; bounds resume granularity).
if dispatched.is_multiple_of(progress_step.max(1)) {
crate::checkpoint::write_seq_marker(&scan_id, ip);
}
match ip.checked_add(1) {
Some(n) => ip = n,
None => break,
}
}
drain_joinset(&mut joinset).await;
let reached_end = ip > LAST_PUBLIC;
let completed_cleanly =
reached_end && !cancel.is_cancelled() && !stats.abort.load(Ordering::Relaxed);
if completed_cleanly {
crate::checkpoint::clear_seq_marker(&scan_id);
} else {
// Save where we stopped so the next run resumes.
crate::checkpoint::write_seq_marker(&scan_id, last_dispatched);
}
let outcome = finalize("Sequential Mass Scan", &stats, total as usize, completed_cleanly);
route_findings(&outcome, module.as_ref()).await;
drop(batch_guard);
Ok(outcome)
}
// ============================================================
// PRE-BATCH PROMPT COLLECTION
// ============================================================
/// Runs the module once against a dummy target BEFORE batch mode activates.
/// This lets `cfg_prompt_*` functions prompt interactively (since batch mode
/// is not yet active). Answers are stored in global options so that once
/// batch mode is entered, all tasks find the values pre-populated and never
/// need to prompt stdin.
///
/// If the module errors during this dry-run (expected — it can't reach the
/// dummy target), the error is ignored as long as prompts were collected.
async fn pre_batch_prompt(
module: &Arc<dyn Module>,
options: &ModuleOptions,
cancel: tokio_util::sync::CancellationToken,
tenant_id: Option<String>,
module_path: &str,
) -> Result<()> {
crate::mprintln!(
"{}",
"[*] Mass scan mode — configuring module options (answers apply to ALL targets)".cyan().bold()
);
crate::mprintln!(
"{}",
" Tip: pre-set values with 'setg <key> <value>' to skip prompts next time.".dimmed()
);
crate::mprintln!();
// Build a context that looks like a normal single-target run.
// batch_mode is false, so cfg_prompt_* will prompt interactively.
let mut ctx = ModuleCtx::new(Target::Single("0.0.0.1".to_string()));
ctx.options = options.clone();
ctx.cancel = cancel;
ctx.tenant_id = tenant_id;
ctx.batch_mode = false;
// Mark this as a prompt-harvest dry run: `cfg_prompt_*` still prompts
// (batch_mode is false), but a module that honours `prompt_only` will return
// immediately after gathering answers instead of doing real work against the
// placeholder host. Output is captured below as a belt-and-suspenders for
// modules that don't yet check the flag.
ctx.prompt_only = true;
ctx.prompt_cache = None;
ctx.module_path = module_path.to_string();
// This dry-run exists ONLY to drive the module's `cfg_prompt_*` calls so a
// human can answer them once before the batch starts. The operator is
// typing at the console here, so the bound must be generous: a 5s deadline
// (the old value) would fire while the operator is still answering, and
// because each prompt is a blocking `spawn_blocking` stdin read that cannot
// be cancelled, killing the dry-run orphaned that reader thread — which then
// raced the next prompt / the shell for input. Keep a large backstop purely
// so a misbehaving module's post-prompt network work can't hang batch setup
// forever (the dummy target 0.0.0.1 normally fails fast); the operator can
// always Ctrl-C.
// Capture the dry-run's console output into a throwaway buffer so it never
// reaches the operator. This phase exists ONLY to drive the module's
// `cfg_prompt_*` calls; without this, a connect-based module (e.g.
// service_scanner) actually "scans" the placeholder host 0.0.0.1 and prints
// a confusing `Scanning 0.0.0.1 … 0 services detected` block. Interactive
// prompts use `print!` (real stdout) and stay visible; only `mprintln!` /
// `meprintln!` module output is suppressed.
let run_fut = crate::output::OUTPUT_BUFFER.scope(
crate::output::OutputBuffer::new(),
module.run(&ctx),
);
let result = tokio::time::timeout(Duration::from_secs(300), run_fut).await;
// The prompts have already been answered and stored by cfg_prompt_*. The
// dry-run's outcome is not actionable (it exists only to harvest prompts),
// so each arm binds and logs it at debug — NOT propagated, because
// propagating an expected dry-run error would abort the whole batch.
match result {
Ok(Ok(outcome)) => tracing::debug!(
"pre_batch_prompt: dry-run completed with {} finding(s) (ignored — placeholder host)",
outcome.findings.len()
),
Ok(Err(e)) => tracing::warn!("pre_batch_prompt: config dry-run errored (prompts may be incomplete): {e:#}"),
Err(elapsed) => tracing::warn!("pre_batch_prompt: config dry-run timed out (prompts may be incomplete): {elapsed}"),
}
crate::mprintln!();
Ok(())
}
/// Print a one-time advisory before a full-internet sweep (`random` /
/// `0.0.0.0` / `0.0.0.0/0`, random or sequential) showing the knobs an operator
/// usually wants to set, then — on an interactive console only — ask for an
/// explicit confirmation. Returns `false` when the operator declines (the
/// caller aborts the sweep). API/MCP and piped/scripted runs are never prompted
/// (they proceed), mirroring how the per-target deadline is gated.
///
/// `cap` is the host ceiling for this sweep: `Some(n)` (random, or sequential
/// with `max_random_hosts` set) or `None` (an unbounded sequential sweep).
async fn full_sweep_advisory_and_confirm(
module: &Arc<dyn Module>,
limits: &SchedulerLimits,
cap: Option<usize>,
sweep_label: &str,
) -> Result<bool> {
// Effective service port for the per-host precheck. `limits.precheck_port`
// is already resolved to `setg port` if set, else the module's own
// `default_port` (see run_with_limits_shared) — exactly the "default to the
// module's port" behaviour. Surface where it came from so a stale global
// `setg port` that differs from the module default is visible.
let module_default = module.info().default_port;
let global_port = match crate::tenant::resolve().global_options().try_get("port") {
Some(v) => match v.trim().parse::<u16>() {
Ok(p) => Some(p),
Err(e) => {
// Surface, don't swallow: a bad `setg port` would otherwise be
// silently ignored and the operator would never know why the
// precheck used a different port.
crate::meprintln!(
"[!] Ignoring invalid `setg port` value '{}': {e}",
v.trim()
);
None
}
},
None => None,
};
let port_line = match (limits.precheck_port, global_port, module_default) {
(Some(p), Some(_), Some(d)) if p != d => {
format!("{p} (from `setg port`; this module's default is {d})")
}
(Some(p), Some(_), _) => format!("{p} (from `setg port`)"),
(Some(p), None, _) => format!("{p} (module default)"),
(None, _, _) => {
"none — this module declares no default port, so every host is probed".to_string()
}
};
let host_cap = match cap {
Some(n) => format!("{n} hosts"),
None => "unbounded (entire public IPv4 space)".to_string(),
};
let excl = crate::exclusions::shared().networks().len();
crate::mprintln!(
"{}",
format!(
"[!] {sweep_label}: '{}' will probe public hosts across the whole Internet.",
module.info().name
)
.yellow()
.bold()
);
crate::mprintln!("{}", " Settings for this sweep (override with `setg <key> <value>`):".yellow());
crate::mprintln!(" {:<17}{}", "port:", port_line);
crate::mprintln!(" {:<17}{} {}", "concurrency:", limits.concurrency, "(setg concurrency <n>)".dimmed());
crate::mprintln!(" {:<17}{} {}", "host cap:", host_cap, "(setg max_random_hosts <n>)".dimmed());
crate::mprintln!(" {:<17}{}s {}", "per-host timeout:", limits.timeout_secs, "(setg timeout <n>)".dimmed());
crate::mprintln!(
" {:<17}{} networks excluded (RFC1918/bogons/...) {}",
"exclusions:",
excl,
"(setg exclusions ...)".dimmed()
);
// Only a human at an interactive console is asked to confirm. API/MCP runs
// (api_mode) and piped/scripted CLI runs (stdin not a TTY) proceed — they
// opted in by passing the target and there is no keyboard to answer.
let cfg = crate::config::get_module_config();
let interactive_console = !cfg.api_mode && std::io::stdin().is_terminal();
if !interactive_console {
return Ok(true);
}
let proceed = crate::utils::prompt_yes_no("Proceed with the full-internet sweep?", false).await?;
if !proceed {
crate::mprintln!("{}", "[*] Full-internet sweep aborted by operator.".yellow());
}
Ok(proceed)
}
// ============================================================
// HELPERS
// ============================================================
/// Wait for every task currently in the per-fanout `JoinSet` to finish.
/// We can't use `Semaphore::acquire_many(concurrency)` for this barrier
/// when the semaphore is shared (a sibling sub-target via `fanout_multi`
/// might still hold permits). The `JoinSet` tracks *this* fanout's spawns
/// only.
async fn drain_joinset(joinset: &mut tokio::task::JoinSet<()>) {
while let Some(res) = joinset.join_next().await {
if let Err(e) = res
&& !e.is_cancelled() {
tracing::debug!("spawned task panicked: {e:#}");
}
}
}
/// Open (or resume) a crash-recovery checkpoint for a fan-out invocation.
/// On resume, prints how many targets were already processed. On failure,
/// logs and returns `None` so the scan continues without resume support.
async fn open_checkpoint(
module_path: &str,
target_label: &str,
) -> Option<Arc<crate::checkpoint::CheckpointWriter>> {
let scan_id = crate::checkpoint::auto_scan_id(module_path, target_label);
match crate::checkpoint::CheckpointWriter::open(&scan_id, module_path, target_label) {
Ok(cp) => {
let prior = cp.count().await;
if prior > 0 {
crate::mprintln!(
"{}",
format!(
"[*] Resuming checkpoint '{}' — {} targets already processed, will skip.",
scan_id, prior
)
.green()
);
}
Some(Arc::new(cp))
}
Err(e) => {
crate::meprintln!(
"[!] Could not open checkpoint: {}. Continuing without resume.",
e
);
None
}
}
}
/// Record a target as processed, swallowing only at debug-trace level so
/// scans don't terminate on transient disk errors.
async fn record_checkpoint(
cp: &Option<Arc<crate::checkpoint::CheckpointWriter>>,
target: &str,
) {
if let Some(cp) = cp.as_ref()
&& let Err(e) = cp.record(target).await {
tracing::debug!(target = %target, "checkpoint record failed: {e:#}");
}
}
/// Final flush + delete-on-success. The checkpoint file is preserved on
/// cancellation/abort so the next run resumes; cleanly-finished scans
/// remove the file.
async fn finalize_checkpoint(
cp: &Option<Arc<crate::checkpoint::CheckpointWriter>>,
completed_cleanly: bool,
) {
let Some(cp) = cp.as_ref() else { return };
if let Err(e) = cp.flush().await {
crate::meprintln!("[!] Checkpoint flush failed: {e:#}");
}
if completed_cleanly
&& let Err(e) = cp.finish().await {
crate::meprintln!("[!] Checkpoint finalize failed: {e:#}");
}
}
fn finalize(label: &str, stats: &ScanStats, total: usize, completed_cleanly: bool) -> ModuleOutcome {
let s = stats.snapshot();
// Publish the final per-target breakdown to the job's scan counters (if this
// run is a backgrounded job that scoped them) so `jobs`/get_detail reports
// real total/succeeded/failed instead of always-zero.
if let Some(c) = crate::context::scan_counters() {
c.total.store(total as u64, Ordering::Relaxed);
c.succeeded.store(s.success as u64, Ordering::Relaxed);
c.failed.store(s.failed as u64, Ordering::Relaxed);
}
crate::mprintln!("\n{}", format!("=== {} Summary ===", label).cyan().bold());
crate::mprintln!(" Hosts examined: {} / {}", s.considered, total);
crate::mprintln!(
" Module ran on: {} ({} ok, {} errored)",
s.processed, s.success, s.failed
);
if s.skipped > 0 {
crate::mprintln!(
" {}",
format!(
"Skipped: {} (no service port / honeypot / already done)",
s.skipped
)
.yellow()
);
}
// The headline number the operator actually cares about: did the scan
// find anything? `success`/`ok` only means a host did not error — it is
// not a hit. Surface findings explicitly so a scan that ran cleanly but
// found nothing reads as "0 findings", not "N successful".
let findings: Vec<Finding> = stats.findings.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.cloned()
.collect();
if s.hits > 0 {
crate::mprintln!(
" {}",
format!("Findings: {} (saved to loot / workspace)", s.hits)
.green()
.bold()
);
// List what was actually found, not just the count. Under a mass scan
// the per-host hit lines scroll away behind progress output, so the
// operator otherwise sees "Findings: N" with no idea *what* — e.g.
// which proxies/hosts. Cap the list so a huge sweep cannot flood the
// terminal; the full set is still persisted to loot / workspace.
const MAX_LISTED: usize = 100;
for f in findings.iter().take(MAX_LISTED) {
crate::mprintln!(" {}", format!("[+] {}", f.message).green());
}
if findings.len() > MAX_LISTED {
crate::mprintln!(
" {}",
format!(
"… and {} more (see loot / workspace)",
findings.len() - MAX_LISTED
)
.dimmed()
);
}
} else {
crate::mprintln!(
" {}",
"Findings: 0 (nothing found)".yellow()
);
}
// A scan that was cancelled (Ctrl-C) or auto-aborted stopped before
// examining every host, so its result is NOT a clean success even if no
// host errored — surfacing it as `success: true` would make an interrupted
// run indistinguishable from a complete one.
ModuleOutcome {
success: completed_cleanly && s.failed == 0,
findings,
}
}
/// Auto-route findings into LootStore / Workspace / events bus.
async fn route_findings(outcome: &ModuleOutcome, module: &dyn Module) {
let module_name = module.info().name;
for f in &outcome.findings {
let kind_str = match f.kind {
FindingKind::Vulnerable => "vulnerable",
FindingKind::Credential => "credential",
FindingKind::OpenPort => "open_port",
FindingKind::Banner => "banner",
FindingKind::Note => "note",
};
match f.kind {
FindingKind::Credential => {
let payload = f
.data
.as_ref()
.and_then(|v| serde_json::to_vec(v).ok())
.unwrap_or_else(|| f.message.as_bytes().to_vec());
if crate::loot::store_loot(
&f.target,
"credential",
&f.message,
&payload,
&module_name,
)
.await
.is_none() {
eprintln!("[!] Failed to store loot for {}", f.target);
}
}
FindingKind::Vulnerable => {
// `add_note` no-ops (silently) for a host that was never
// tracked, so register it first — otherwise a vuln found on a
// freshly-seen host (or a non-IP target like a BLE MAC) would
// be dropped on the floor.
crate::workspace::track_host(&f.target, None, None).await;
crate::workspace::add_note(
&f.target,
&format!("[{}] {}", module_name, f.message),
)
.await;
}
FindingKind::OpenPort | FindingKind::Banner | FindingKind::Note => {
crate::workspace::track_host(&f.target, None, None).await;
if !f.message.is_empty() {
crate::workspace::add_note(
&f.target,
&format!("[{}] {}", module_name, f.message),
)
.await;
}
}
}
crate::events::emit(crate::events::ModuleEvent::Finding {
module: module_name.clone(),
target: f.target.clone(),
kind: kind_str.to_string(),
message: f.message.clone(),
});
}
}