1use std::net::IpAddr;
8
9use clap::Subcommand;
10
11use super::style::Palette;
12use super::{CliError, resolve_profile};
13use crate::config::Config;
14use crate::filter::explain::{
15 Subject, explain, explanation_json, render_explanation, render_policy,
16};
17use crate::sqlite::order::Identifier;
18
19#[derive(Subcommand)]
20pub enum FilterCommand {
21 Show {
23 #[arg(long)]
25 profile: Option<String>,
26 },
27 Explain {
33 #[arg(long)]
34 profile: Option<String>,
35 #[arg(long)]
37 client_ip: Option<IpAddr>,
38 #[arg(long = "identifier")]
40 identifiers: Vec<String>,
41 #[arg(long, default_value = "/newOrder")]
43 path: String,
44 #[arg(long, default_value = "explain")]
46 account_id: String,
47 #[arg(long)]
48 json: bool,
49 },
50}
51
52pub async fn run_filter_command(
53 command: FilterCommand,
54 palette: Palette,
55 config: &Config,
56) -> Result<(), CliError> {
57 match command {
58 FilterCommand::Show { profile } => {
59 let (name, policy) = build(config, profile.as_deref())?;
60 print!("{}", render_policy(&name, &policy, palette));
61 Ok(())
62 }
63 FilterCommand::Explain {
64 profile,
65 client_ip,
66 identifiers,
67 path,
68 account_id,
69 json,
70 } => {
71 let (name, policy) = build(config, profile.as_deref())?;
72 let subject = Subject {
73 client_ip,
74 account_id,
75 identifiers: identifiers.iter().map(Identifier::dns).collect(),
76 path,
77 eab: None,
78 };
79
80 let explanation = explain(&policy, &subject).await;
81 if json {
82 let value = explanation_json(&name, &subject, &explanation);
83 println!(
84 "{}",
85 serde_json::to_string_pretty(&value)
86 .map_err(|error| CliError(format!("cannot render JSON: {error}")))?
87 );
88 } else {
89 print!(
90 "{}",
91 render_explanation(&name, &subject, &explanation, palette)
92 );
93 }
94 Ok(())
95 }
96 }
97}
98
99fn build(
111 config: &Config,
112 wanted: Option<&str>,
113) -> Result<(String, crate::filter::FilterPolicy), CliError> {
114 let profile = resolve_profile(config, wanted)?;
115 let sections = &profile.sections;
116
117 let resolver = crate::dns::HickoryResolver::from_system_uncached()
118 .map_err(|error| CliError(format!("cannot build a resolver: {error}")))?;
119 let proxies = crate::proxy::OutboundProxies::from_config(&config.proxy)
120 .map_err(|error| CliError(format!("configuration error: {error}")))?;
121
122 let inventory = crate::ipam::from_config(
123 §ions.ipam,
124 crate::http_client::Outbound::new(
125 std::sync::Arc::new(resolver),
126 std::sync::Arc::new(proxies),
127 ),
128 )
129 .map_err(|error| CliError(format!("profile `{}`: {error}", profile.name)))?;
130
131 let policy = crate::filter::build::build(
132 §ions.filter,
133 &config.dns,
134 inventory,
135 sections.eab.enabled,
136 )
137 .map_err(|error| CliError(format!("profile `{}`: {error}", profile.name)))?;
138
139 Ok((profile.name, policy))
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145 use crate::config::ENV_LOCK;
146
147 fn load(body: &str) -> Config {
153 let _lock = ENV_LOCK
154 .lock()
155 .unwrap_or_else(std::sync::PoisonError::into_inner);
156 let dir = crate::testutil::TempDir::new("cli-filter");
157 std::fs::write(dir.join("config.toml"), body).unwrap();
158 unsafe {
160 std::env::set_var("ACME_PROXY_CONFIG", dir.join("config").to_str().unwrap());
161 }
162 let config = Config::load().expect("the configuration must load");
163 unsafe {
164 std::env::remove_var("ACME_PROXY_CONFIG");
165 }
166 config
167 }
168
169 const ONE_PROFILE: &str = r#"
170 [profiles.default]
171 [profiles.default.filter]
172 rules = ["mgmt"]
173 rule.mgmt.when = "net"
174 rule.mgmt.then = "allow"
175 check.net.type = "allowed_ip"
176 check.net.allow = ["10.0.0.0/8"]
177 "#;
178
179 async fn run(config: &Config, command: FilterCommand) -> Result<(), CliError> {
180 run_filter_command(command, Palette::plain(), config).await
181 }
182
183 #[tokio::test]
184 async fn show_prints_the_policy_of_the_only_profile() {
185 let config = load(ONE_PROFILE);
186 assert!(
187 run(&config, FilterCommand::Show { profile: None })
188 .await
189 .is_ok()
190 );
191 }
192
193 #[tokio::test]
194 async fn show_accepts_the_profile_by_name() {
195 let config = load(ONE_PROFILE);
196 assert!(
197 run(
198 &config,
199 FilterCommand::Show {
200 profile: Some("default".to_string())
201 }
202 )
203 .await
204 .is_ok()
205 );
206 }
207
208 #[tokio::test]
209 async fn an_unknown_profile_is_refused_by_name() {
210 let config = load(ONE_PROFILE);
211 let error = run(
212 &config,
213 FilterCommand::Show {
214 profile: Some("nope".to_string()),
215 },
216 )
217 .await
218 .unwrap_err();
219 assert!(error.0.contains("no profile named `nope`"), "{}", error.0);
220 }
221
222 #[tokio::test]
225 async fn several_profiles_require_naming_one() {
226 let config = load(
227 r#"
228 [profiles.a]
229 [profiles.b]
230 "#,
231 );
232 let error = run(&config, FilterCommand::Show { profile: None })
233 .await
234 .unwrap_err();
235 assert!(error.0.contains("--profile"), "{}", error.0);
236 }
237
238 #[tokio::test]
242 async fn a_broken_policy_is_reported_rather_than_printed() {
243 let config = load(
244 r#"
245 [profiles.default]
246 [profiles.default.filter]
247 rules = ["broken"]
248 rule.broken.when = "net and )"
249 rule.broken.then = "allow"
250 check.net.type = "allowed_ip"
251 check.net.allow = ["10.0.0.0/8"]
252 "#,
253 );
254 let error = run(&config, FilterCommand::Show { profile: None })
255 .await
256 .unwrap_err();
257 assert!(error.0.contains("at column"), "{}", error.0);
258 }
259
260 fn explain_of(profile: Option<String>, ip: &str, names: &[&str], json: bool) -> FilterCommand {
261 FilterCommand::Explain {
262 profile,
263 client_ip: Some(ip.parse().unwrap()),
264 identifiers: names.iter().map(std::string::ToString::to_string).collect(),
265 path: "/newOrder".to_string(),
266 account_id: "explain".to_string(),
267 json,
268 }
269 }
270
271 #[tokio::test]
272 async fn explain_runs_in_both_output_shapes() {
273 let config = load(ONE_PROFILE);
274 for json in [false, true] {
275 assert!(
276 run(
277 &config,
278 explain_of(None, "10.0.0.5", &["a.example.com"], json)
279 )
280 .await
281 .is_ok(),
282 "json = {json}"
283 );
284 }
285 }
286
287 #[tokio::test]
288 async fn explain_works_on_a_refused_address_too() {
289 let config = load(ONE_PROFILE);
290 assert!(
291 run(&config, explain_of(None, "203.0.113.9", &[], false))
292 .await
293 .is_ok()
294 );
295 }
296
297 #[tokio::test]
300 async fn explain_handles_a_policy_with_no_rules() {
301 let config = load("[profiles.default]\n");
302 assert!(
303 run(
304 &config,
305 explain_of(None, "10.0.0.5", &["a.example.com"], false)
306 )
307 .await
308 .is_ok()
309 );
310 }
311
312 #[tokio::test]
313 async fn explain_accepts_no_client_address_at_all() {
314 let config = load(ONE_PROFILE);
315 let command = FilterCommand::Explain {
316 profile: None,
317 client_ip: None,
318 identifiers: Vec::new(),
319 path: "/directory".to_string(),
320 account_id: "explain".to_string(),
321 json: false,
322 };
323 assert!(run(&config, command).await.is_ok());
324 }
325}