1use std::net::IpAddr;
9
10use clap::Subcommand;
11
12use super::{CliError, resolve_profile};
13use acme_proxy_core::config::Config;
14use acme_proxy_core::identifier::Identifier;
15use acme_proxy_core::palette::Palette;
16use acme_proxy_policy::filter::explain::Subject;
17use acme_proxy_policy::filter::explain::explain;
18use acme_proxy_policy::filter::explain::explanation_json;
19use acme_proxy_policy::filter::explain::policy_json;
20use acme_proxy_policy::filter::explain::render_explanation;
21use acme_proxy_policy::filter::explain::render_policy;
22
23#[derive(Subcommand)]
24pub enum FilterCommand {
25 Show {
27 #[arg(long)]
29 profile: Option<String>,
30 #[arg(long)]
32 json: bool,
33 },
34 Explain {
40 #[arg(long)]
42 profile: Option<String>,
43 #[arg(long)]
45 client_ip: Option<IpAddr>,
46 #[arg(long = "identifier")]
48 identifiers: Vec<String>,
49 #[arg(long, default_value = "/newOrder")]
51 path: String,
52 #[arg(long, default_value = "explain")]
54 account_id: String,
55 #[arg(long)]
57 json: bool,
58 },
59}
60
61pub async fn run_filter_command(
62 command: FilterCommand,
63 palette: Palette,
64 config: &Config,
65) -> Result<(), CliError> {
66 match command {
67 FilterCommand::Show { profile, json } => {
68 let (name, policy) = build(config, profile.as_deref())?;
69 if json {
70 print_json(&policy_json(&name, &policy))?;
71 } else {
72 print!("{}", render_policy(&name, &policy, palette));
73 }
74 Ok(())
75 }
76 FilterCommand::Explain {
77 profile,
78 client_ip,
79 identifiers,
80 path,
81 account_id,
82 json,
83 } => {
84 let (name, policy) = build(config, profile.as_deref())?;
85 let subject = Subject {
86 client_ip,
87 account_id,
88 identifiers: identifiers.iter().map(Identifier::dns).collect(),
89 path,
90 eab: None,
91 };
92
93 let explanation = explain(&policy, &subject).await;
94 if json {
95 print_json(&explanation_json(&name, &subject, &explanation))?;
96 } else {
97 print!(
98 "{}",
99 render_explanation(&name, &subject, &explanation, palette)
100 );
101 }
102 Ok(())
103 }
104 }
105}
106
107fn print_json(value: &serde_json::Value) -> Result<(), CliError> {
116 println!(
117 "{}",
118 serde_json::to_string_pretty(value)
119 .map_err(|error| CliError::failed(format!("cannot render JSON: {error}")))?
120 );
121 Ok(())
122}
123
124fn build(
136 config: &Config,
137 wanted: Option<&str>,
138) -> Result<(String, acme_proxy_policy::filter::FilterPolicy), CliError> {
139 let profile = resolve_profile(config, wanted)?;
140 let sections = &profile.sections;
141
142 let resolver = acme_proxy_net::dns::HickoryResolver::from_system_uncached()
143 .map_err(|error| CliError::failed(format!("cannot build a resolver: {error}")))?;
144 let proxies = acme_proxy_net::proxy::OutboundProxies::from_config(&config.proxy)
145 .map_err(|error| CliError::failed(format!("configuration error: {error}")))?;
146
147 let inventory = acme_proxy_policy::ipam::from_config(
148 §ions.ipam,
149 acme_proxy_net::http_client::Outbound::new(
150 std::sync::Arc::new(resolver),
151 std::sync::Arc::new(proxies),
152 ),
153 )
154 .map_err(|error| CliError::failed(format!("profile `{}`: {error}", profile.name)))?;
155
156 let policy = acme_proxy_policy::filter::build::build(
157 §ions.filter,
158 &config.dns,
159 inventory,
160 sections.eab.enabled,
161 )
162 .map_err(|error| CliError::failed(format!("profile `{}`: {error}", profile.name)))?;
163
164 Ok((profile.name, policy))
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use acme_proxy_core::config::ENV_LOCK;
171
172 fn load(body: &str) -> Config {
178 let _lock = ENV_LOCK
179 .lock()
180 .unwrap_or_else(std::sync::PoisonError::into_inner);
181 let dir = acme_proxy_core::testutil::TempDir::new("cli-filter");
182 std::fs::write(dir.join("config.toml"), body).unwrap();
183 unsafe {
185 std::env::set_var("ACME_PROXY_CONFIG", dir.join("config").to_str().unwrap());
186 }
187 let config = Config::load().expect("the configuration must load");
188 unsafe {
189 std::env::remove_var("ACME_PROXY_CONFIG");
190 }
191 config
192 }
193
194 const ONE_PROFILE: &str = r#"
195 [profiles.default]
196 [profiles.default.filter]
197 rules = ["mgmt"]
198 rule.mgmt.when = "net"
199 rule.mgmt.then = "allow"
200 check.net.type = "allowed_ip"
201 check.net.allow = ["10.0.0.0/8"]
202 "#;
203
204 async fn run(config: &Config, command: FilterCommand) -> Result<(), CliError> {
205 run_filter_command(command, Palette::plain(), config).await
206 }
207
208 #[tokio::test]
209 async fn show_prints_the_policy_of_the_only_profile() {
210 let config = load(ONE_PROFILE);
211 assert!(
212 run(
213 &config,
214 FilterCommand::Show {
215 profile: None,
216 json: false
217 }
218 )
219 .await
220 .is_ok()
221 );
222 }
223
224 #[tokio::test]
225 async fn show_accepts_the_profile_by_name() {
226 let config = load(ONE_PROFILE);
227 assert!(
228 run(
229 &config,
230 FilterCommand::Show {
231 profile: Some("default".to_string()),
232 json: false
233 }
234 )
235 .await
236 .is_ok()
237 );
238 }
239
240 #[tokio::test]
241 async fn an_unknown_profile_is_refused_by_name() {
242 let config = load(ONE_PROFILE);
243 let error = run(
244 &config,
245 FilterCommand::Show {
246 profile: Some("nope".to_string()),
247 json: false,
248 },
249 )
250 .await
251 .unwrap_err();
252 assert!(
253 error.message.contains("no profile named `nope`"),
254 "{}",
255 error.message
256 );
257 }
258
259 #[tokio::test]
262 async fn several_profiles_require_naming_one() {
263 let config = load(
264 r#"
265 [profiles.a]
266 [profiles.b]
267 "#,
268 );
269 let error = run(
270 &config,
271 FilterCommand::Show {
272 profile: None,
273 json: false,
274 },
275 )
276 .await
277 .unwrap_err();
278 assert!(error.message.contains("--profile"), "{}", error.message);
279 }
280
281 #[tokio::test]
285 async fn a_broken_policy_is_reported_rather_than_printed() {
286 let config = load(
287 r#"
288 [profiles.default]
289 [profiles.default.filter]
290 rules = ["broken"]
291 rule.broken.when = "net and )"
292 rule.broken.then = "allow"
293 check.net.type = "allowed_ip"
294 check.net.allow = ["10.0.0.0/8"]
295 "#,
296 );
297 for json in [false, true] {
301 let error = run(
302 &config,
303 FilterCommand::Show {
304 profile: None,
305 json,
306 },
307 )
308 .await
309 .unwrap_err();
310 assert!(error.message.contains("at column"), "{}", error.message);
311 }
312 }
313
314 #[tokio::test]
316 async fn show_runs_in_both_output_shapes() {
317 let config = load(ONE_PROFILE);
318 for json in [false, true] {
319 assert!(
320 run(
321 &config,
322 FilterCommand::Show {
323 profile: None,
324 json
325 }
326 )
327 .await
328 .is_ok(),
329 "show --json={json} must succeed"
330 );
331 }
332 }
333
334 fn explain_of(profile: Option<String>, ip: &str, names: &[&str], json: bool) -> FilterCommand {
335 FilterCommand::Explain {
336 profile,
337 client_ip: Some(ip.parse().unwrap()),
338 identifiers: names.iter().map(std::string::ToString::to_string).collect(),
339 path: "/newOrder".to_string(),
340 account_id: "explain".to_string(),
341 json,
342 }
343 }
344
345 #[tokio::test]
346 async fn explain_runs_in_both_output_shapes() {
347 let config = load(ONE_PROFILE);
348 for json in [false, true] {
349 assert!(
350 run(
351 &config,
352 explain_of(None, "10.0.0.5", &["a.example.com"], json)
353 )
354 .await
355 .is_ok(),
356 "json = {json}"
357 );
358 }
359 }
360
361 #[tokio::test]
362 async fn explain_works_on_a_refused_address_too() {
363 let config = load(ONE_PROFILE);
364 assert!(
365 run(&config, explain_of(None, "203.0.113.9", &[], false))
366 .await
367 .is_ok()
368 );
369 }
370
371 #[tokio::test]
374 async fn explain_handles_a_policy_with_no_rules() {
375 let config = load("[profiles.default]\n");
376 assert!(
377 run(
378 &config,
379 explain_of(None, "10.0.0.5", &["a.example.com"], false)
380 )
381 .await
382 .is_ok()
383 );
384 }
385
386 #[tokio::test]
387 async fn explain_accepts_no_client_address_at_all() {
388 let config = load(ONE_PROFILE);
389 let command = FilterCommand::Explain {
390 profile: None,
391 client_ip: None,
392 identifiers: Vec::new(),
393 path: "/directory".to_string(),
394 account_id: "explain".to_string(),
395 json: false,
396 };
397 assert!(run(&config, command).await.is_ok());
398 }
399}