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 30-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 normalize_domain(raw: &str) -> Option<String> {
37 let raw = raw.trim();
38 let raw = raw.strip_prefix("*.").unwrap_or(raw);
39 let candidate = if raw.contains("://") {
40 raw.to_string()
41 } else {
42 format!("https://{raw}")
43 };
44 let url = url::Url::parse(&candidate).ok()?;
45 let host = url.host_str()?.trim_end_matches('.').to_ascii_lowercase();
46 let host = match host.strip_prefix("www.") {
47 Some(rest) if rest.contains('.') => rest.to_string(),
48 _ => host,
49 };
50 (!host.is_empty()).then_some(host)
51}
52
53pub fn run() -> i32 {
54 let mut domain: Option<String> = None;
55 let mut mode = Mode::Combined;
56 let mut refresh = false;
57 let mut probe = true;
58 let mut ports: Option<Vec<u16>> = None;
59
60 for arg in std::env::args().skip(1) {
61 match arg.as_str() {
62 "--refresh" => refresh = true,
63 "--no-probe" => probe = false,
64 "--ports" => ports = Some(Vec::new()),
65 other if other.starts_with("--ports=") => {
66 let list = other
67 .trim_start_matches("--ports=")
68 .split(',')
69 .filter_map(|p| p.trim().parse().ok())
70 .collect::<Vec<u16>>();
71 if list.is_empty() {
72 eprintln!("ERROR: --ports=LIST requires a comma-separated port list");
73 return 2;
74 }
75 ports = Some(list);
76 }
77 "--summary" => mode = Mode::Summary,
78 "--hosts" => mode = Mode::Hosts,
79 "--certs" => mode = Mode::Certs,
80 "--graph" => mode = Mode::Graph,
81 "--json" => mode = Mode::Json,
82 "-h" | "--help" => {
83 print!("{USAGE}");
84 return 0;
85 }
86 other if other.starts_with("--") => {
87 eprintln!("ERROR: unknown option: {other}");
88 eprint!("{USAGE}");
89 return 2;
90 }
91 other => {
92 if domain.is_none() {
93 domain = Some(other.to_string());
94 } else {
95 eprintln!("ERROR: unexpected argument: {other}");
96 eprint!("{USAGE}");
97 return 2;
98 }
99 }
100 }
101 }
102
103 let Some(raw) = domain else {
104 eprint!("{USAGE}");
105 return 2;
106 };
107 let Some(domain) = normalize_domain(&raw) else {
108 eprintln!("ERROR: cannot derive a domain from: {raw}");
109 return 2;
110 };
111
112 let cfg = Config::from_env();
113 let runtime = match tokio::runtime::Builder::new_multi_thread()
114 .enable_all()
115 .build()
116 {
117 Ok(rt) => rt,
118 Err(e) => {
119 eprintln!("ERROR: cannot start async runtime: {e}");
120 return 1;
121 }
122 };
123
124 match runtime.block_on(collect::collect(
125 &domain,
126 &cfg,
127 &Options {
128 refresh,
129 probe,
130 ports,
131 },
132 )) {
133 Ok(state) => {
134 match mode {
135 Mode::Combined => print!("{}", render::text::combined(&state)),
136 Mode::Summary => print!("{}", render::text::summary(&state)),
137 Mode::Hosts => print!("{}", render::text::hosts(&state)),
138 Mode::Certs => print!("{}", render::text::certs(&state)),
139 Mode::Graph => println!("{}", render::graph::graph(&state)),
140 Mode::Json => match serde_json::to_string_pretty(&state) {
141 Ok(json) => println!("{json}"),
142 Err(e) => {
143 eprintln!("ERROR: JSON serialization failed: {e}");
144 return 1;
145 }
146 },
147 }
148 0
149 }
150 Err(e) => {
151 eprintln!("ERROR: {e}");
152 1
153 }
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use super::normalize_domain;
160
161 #[test]
162 fn strips_scheme_path_and_www() {
163 assert_eq!(
164 normalize_domain("https://www.example.com/").as_deref(),
165 Some("example.com")
166 );
167 }
168
169 #[test]
170 fn accepts_bare_hosts_and_ports() {
171 assert_eq!(
172 normalize_domain("example.com").as_deref(),
173 Some("example.com")
174 );
175 assert_eq!(
176 normalize_domain("WWW.Example.COM.:8443").as_deref(),
177 Some("example.com")
178 );
179 }
180
181 #[test]
182 fn strips_userinfo_query_and_wildcard() {
183 assert_eq!(
184 normalize_domain("https://user:pw@example.com:8443/a?b=1#c").as_deref(),
185 Some("example.com")
186 );
187 assert_eq!(
188 normalize_domain("*.sub.example.com").as_deref(),
189 Some("sub.example.com")
190 );
191 }
192
193 #[test]
194 fn keeps_single_label_www_hosts() {
195 assert_eq!(normalize_domain("www.com").as_deref(), Some("www.com"));
196 }
197
198 #[test]
199 fn rejects_empty_input() {
200 assert_eq!(normalize_domain(" "), None);
201 }
202}