1use crate::collect::{self, Options};
2use crate::config::Config;
3use crate::render;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6enum Mode {
7 Combined,
8 Summary,
9 Hosts,
10 Certs,
11 Graph,
12 Json,
13}
14
15const USAGE: &str = "\
16Usage:
17 pq-map DOMAIN [--refresh] [--no-probe] [--summary|--hosts|--certs|--graph|--json]
18
19Modes:
20 (default) combined text view: names, hosts, services, DNS records
21 --summary certificate estate summary
22 --hosts concrete hostnames of currently valid certificates
23 --certs CT certificate records table
24 --graph host/crypto edges as JSON for 3d-force-graph
25 --json full state as JSON
26
27Options:
28 --refresh force a fresh CT snapshot (bypasses the 7-day cache)
29 --no-probe skip live TLS probing
30 --ports discover services on alternative ports for hosts whose 443 is
31 closed: family-driven port list (mx -> 465/587/25, mail -> 993/995/
32 465/587, ...) or explicit comma LIST (--ports=80,8443)
33 -h, --help this help
34";
35
36pub fn run() -> i32 {
37 let mut domain: Option<String> = None;
38 let mut mode = Mode::Combined;
39 let mut refresh = false;
40 let mut probe = true;
41 let mut ports: Option<Vec<u16>> = None;
42
43 for arg in std::env::args().skip(1) {
44 match arg.as_str() {
45 "--refresh" => refresh = true,
46 "--no-probe" => probe = false,
47 "--ports" => ports = Some(Vec::new()),
48 other if other.starts_with("--ports=") => {
49 let list = other
50 .trim_start_matches("--ports=")
51 .split(',')
52 .filter_map(|p| p.trim().parse().ok())
53 .collect::<Vec<u16>>();
54 if list.is_empty() {
55 eprintln!("ERROR: --ports=LIST requires a comma-separated port list");
56 return 2;
57 }
58 ports = Some(list);
59 }
60 "--summary" => mode = Mode::Summary,
61 "--hosts" => mode = Mode::Hosts,
62 "--certs" => mode = Mode::Certs,
63 "--graph" => mode = Mode::Graph,
64 "--json" => mode = Mode::Json,
65 "-h" | "--help" => {
66 print!("{USAGE}");
67 return 0;
68 }
69 other if other.starts_with("--") => {
70 eprintln!("ERROR: unknown option: {other}");
71 eprint!("{USAGE}");
72 return 2;
73 }
74 other => {
75 if domain.is_none() {
76 domain = Some(other.to_string());
77 } else {
78 eprintln!("ERROR: unexpected argument: {other}");
79 eprint!("{USAGE}");
80 return 2;
81 }
82 }
83 }
84 }
85
86 let Some(domain) = domain else {
87 eprint!("{USAGE}");
88 return 2;
89 };
90 let domain = domain.to_lowercase();
91 let domain = domain.strip_suffix('.').unwrap_or(&domain).to_string();
92
93 let cfg = Config::from_env();
94 let runtime = match tokio::runtime::Builder::new_multi_thread()
95 .enable_all()
96 .build()
97 {
98 Ok(rt) => rt,
99 Err(e) => {
100 eprintln!("ERROR: cannot start async runtime: {e}");
101 return 1;
102 }
103 };
104
105 match runtime.block_on(collect::collect(
106 &domain,
107 &cfg,
108 &Options {
109 refresh,
110 probe,
111 ports,
112 },
113 )) {
114 Ok(state) => {
115 match mode {
116 Mode::Combined => print!("{}", render::text::combined(&state)),
117 Mode::Summary => print!("{}", render::text::summary(&state)),
118 Mode::Hosts => print!("{}", render::text::hosts(&state)),
119 Mode::Certs => print!("{}", render::text::certs(&state)),
120 Mode::Graph => println!("{}", render::graph::graph(&state)),
121 Mode::Json => match serde_json::to_string_pretty(&state) {
122 Ok(json) => println!("{json}"),
123 Err(e) => {
124 eprintln!("ERROR: JSON serialization failed: {e}");
125 return 1;
126 }
127 },
128 }
129 0
130 }
131 Err(e) => {
132 eprintln!("ERROR: {e}");
133 1
134 }
135 }
136}