use clap::{ArgAction, Args, Parser, Subcommand};
use zond_engine::PortSet;
use zond_engine::ZondConfig;
use zond_engine::config::{OsDetection, ScanEffort, SendMode};
use zond_engine::model::technique::TcpScanTechnique;
use crate::diagnostics::Verbosity;
use crate::settings::Presentation;
#[derive(Debug, Parser)]
#[command(
name = "zond",
version,
about = "Find what is on a network.",
long_about = "Find what is on a network.\n\n\
Zond discovers which hosts on a network are alive. Discovery uses ARP \
and ICMPv6 on the local segment and raw TCP elsewhere, which needs root; \
without it the scan falls back to ordinary TCP connect attempts and says \
so.",
propagate_version = true,
arg_required_else_help = true
)]
pub(crate) struct Cli {
#[command(flatten)]
pub output: OutputArgs,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub(crate) enum Command {
#[command(visible_alias = "d")]
Discover(DiscoverArgs),
#[command(visible_alias = "s")]
Scan(ScanArgs),
}
#[derive(Debug, Args)]
#[command(after_help = discover_help())]
pub(crate) struct DiscoverArgs {
#[arg(value_name = "TARGET", required = true, num_args = 1..)]
pub targets: Vec<String>,
#[command(flatten)]
pub engine: EngineArgs,
}
#[derive(Debug, Args)]
#[command(after_help = scan_help())]
pub(crate) struct ScanArgs {
#[arg(value_name = "TARGET", required = true, num_args = 1..)]
pub targets: Vec<String>,
#[arg(short = 'p', long, value_name = "PORTS")]
pub ports: Option<PortSet>,
#[arg(long)]
pub assume_up: bool,
#[arg(long, value_name = "TECHNIQUE")]
pub tcp_technique: Option<TcpScanTechnique>,
#[command(flatten)]
pub engine: EngineArgs,
}
impl ScanArgs {
pub(crate) fn apply_to(&self, config: &mut ZondConfig) {
self.engine.apply_to(config);
if self.assume_up {
config.assume_up = true;
}
if let Some(technique) = self.tcp_technique {
config.tcp_technique = technique;
}
}
}
const TARGET_FORMS: &str = "\
Target forms:
192.168.0.1 one address
192.168.0.1-50 a range; the end continues the start's octets
192.168.0.0/24 a CIDR block
2001:db8::1 one IPv6 address
2001:db8::/120 an IPv6 prefix
fe80::1%en0 a link-local address, on a named interface
one.one.one.one a hostname, resolved before the scan (unless --no-dns)
lan this host's own segment
";
const STOPPING: &str = "
Stopping a run:
q, or Ctrl-C, stops the scan and reports what was found so far. Either again
leaves without waiting for the probes still in flight. Reading a keypress
needs a terminal; in a pipe or a script, Ctrl-C is the one that works.
";
fn discover_help() -> String {
[
TARGET_FORMS,
"
Examples:
sudo zond discover 192.168.0.0/24
sudo zond d 192.168.0.1-50
sudo zond d lan
sudo zond d 2001:db8::1,2001:db8::2
sudo zond d one.one.one.one
",
STOPPING,
"
Discovery uses raw sockets when it can. Without root it falls back to TCP
connect attempts, which find fewer hosts; the summary says which one ran.",
]
.concat()
}
fn scan_help() -> String {
[
TARGET_FORMS,
"
Examples:
sudo zond scan 192.168.0.150 -p 22,80,443
sudo zond s 192.168.0.0/24 -p 1-1024
sudo zond s 10.0.0.1:8080 lan -p 80,443
sudo zond s 2001:db8::1 -p u:53
A scan checks each target is there before probing its ports, and skips the ones
that answer nothing. --assume-up scans them anyway.
",
STOPPING,
"
Port scanning uses raw SYN probes when it can. Without root every port is tested
by completing a connection, which is slower and more visible; the summary says
which one ran.",
]
.concat()
}
#[derive(Debug, Args)]
#[command(next_help_heading = "Scan settings")]
pub(crate) struct EngineArgs {
#[arg(short = 'n', long)]
pub no_dns: bool,
#[arg(long)]
pub redact: bool,
#[arg(long, value_name = "LEVEL")]
pub effort: Option<ScanEffort>,
#[arg(long, value_name = "N", value_parser = clap::value_parser!(u8).range(1..))]
pub max_attempts: Option<u8>,
#[arg(long, value_name = "FACTOR", value_parser = positive)]
pub timeout_scale: Option<f64>,
#[arg(long)]
pub no_dampen: bool,
#[arg(long, value_name = "PPS", value_parser = clap::value_parser!(u32).range(1..))]
pub max_probe_rate: Option<u32>,
#[arg(long, value_name = "MODE")]
pub send_mode: Option<SendMode>,
#[arg(long, value_name = "LEVEL")]
pub os_detection: Option<OsDetection>,
#[arg(long, value_name = "NAME")]
pub profile: Option<String>,
}
fn positive(text: &str) -> Result<f64, String> {
let value: f64 = text
.parse()
.map_err(|_| format!("'{text}' is not a number"))?;
if value.is_finite() && value > 0.0 {
Ok(value)
} else {
Err(format!("'{text}' must be greater than zero"))
}
}
impl EngineArgs {
pub(crate) fn apply_to(&self, config: &mut ZondConfig) {
if self.no_dns {
config.no_dns = true;
}
if self.redact {
config.redact = true;
}
if self.no_dampen {
config.retry.dampen_silent_hosts = false;
}
if let Some(effort) = self.effort {
config.retry.effort = effort;
}
if let Some(attempts) = self.max_attempts {
config.retry.max_attempts = Some(attempts);
}
if let Some(scale) = self.timeout_scale {
config.retry.timeout_scale = Some(scale);
}
if let Some(rate) = self.max_probe_rate {
config.max_probe_rate = Some(rate);
}
if let Some(mode) = self.send_mode {
config.send_mode = mode;
}
if let Some(detection) = self.os_detection {
config.os_detection = detection;
}
}
}
#[derive(Debug, Args)]
#[command(next_help_heading = "Output")]
pub(crate) struct OutputArgs {
#[arg(short = 'v', long, action = ArgAction::Count, global = true)]
pub verbose: u8,
#[arg(short = 'q', long, global = true, conflicts_with = "verbose")]
pub quiet: bool,
#[arg(long, value_name = "MODE", global = true)]
pub presentation: Option<Presentation>,
#[arg(long, global = true, conflicts_with = "presentation")]
pub pipe: bool,
}
impl OutputArgs {
#[must_use]
pub(crate) fn verbosity(&self) -> Verbosity {
Verbosity::new(self.verbose, self.quiet)
}
#[must_use]
pub(crate) fn presentation(&self, configured: Option<Presentation>) -> Presentation {
if self.pipe {
return Presentation::Pipe;
}
self.presentation.or(configured).unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn the_command_definition_is_well_formed() {
Cli::command().debug_assert();
}
#[test]
fn discover_accepts_its_short_alias_and_several_targets() {
let cli = Cli::try_parse_from(["zond", "d", "10.0.0.1", "lan"]).expect("should parse");
let Command::Discover(args) = cli.command else {
panic!("d is the discover alias");
};
assert_eq!(args.targets, ["10.0.0.1", "lan"]);
}
#[test]
fn verbosity_is_accepted_before_or_after_the_subcommand() {
let before = Cli::try_parse_from(["zond", "-vv", "d", "lan"]).expect("should parse");
let after = Cli::try_parse_from(["zond", "d", "-vv", "lan"]).expect("should parse");
assert_eq!(before.output.verbose, 2);
assert_eq!(after.output.verbose, 2);
}
#[test]
fn quiet_and_verbose_together_are_refused() {
assert!(Cli::try_parse_from(["zond", "-q", "-v", "d", "lan"]).is_err());
}
#[test]
fn the_presentation_flag_beats_the_settings_file() {
let with_flag = Cli::try_parse_from(["zond", "--presentation", "fancy", "d", "lan"])
.expect("should parse");
assert_eq!(
with_flag.output.presentation(Some(Presentation::Minimal)),
Presentation::Fancy
);
let without = Cli::try_parse_from(["zond", "d", "lan"]).expect("should parse");
assert_eq!(
without.output.presentation(Some(Presentation::Fancy)),
Presentation::Fancy,
"with no flag the file decides"
);
assert_eq!(
without.output.presentation(None),
Presentation::default(),
"with neither, the built-in default"
);
}
#[test]
fn the_pipe_shorthand_selects_the_pipe_mode() {
let short = Cli::try_parse_from(["zond", "--pipe", "d", "lan"]).expect("should parse");
assert_eq!(
short.output.presentation(Some(Presentation::Fancy)),
Presentation::Pipe
);
assert!(
Cli::try_parse_from(["zond", "--pipe", "--presentation", "minimal", "d", "lan"])
.is_err(),
"two ways of naming a mode at once has no coherent meaning"
);
}
#[test]
fn an_absent_flag_does_not_overrule_the_settings_file() {
let cli = Cli::try_parse_from(["zond", "d", "lan"]).expect("should parse");
let Command::Discover(args) = cli.command else {
panic!("d is the discover alias");
};
let mut from_file = ZondConfig {
no_dns: true,
..ZondConfig::default()
};
args.engine.apply_to(&mut from_file);
assert!(from_file.no_dns, "the flag was not given and said nothing");
}
#[test]
fn the_flag_turns_the_setting_on_when_the_file_is_silent() {
let cli = Cli::try_parse_from(["zond", "d", "-n", "lan"]).expect("should parse");
let Command::Discover(args) = cli.command else {
panic!("d is the discover alias");
};
let mut config = ZondConfig::default();
args.engine.apply_to(&mut config);
assert!(config.no_dns);
}
#[test]
fn a_target_is_required() {
assert!(Cli::try_parse_from(["zond", "discover"]).is_err());
}
}