Skip to main content

acme_proxy/cli/
filter.rs

1//! `acme-proxy filter show|explain` — reading the configured access policy.
2//!
3//! Argument marshalling and profile resolution only; everything printed comes
4//! from [`crate::filter::explain`], which is what lets the panel serve `show`
5//! from the same renderings without moving any logic. `explain` stays here and
6//! only here; why is recorded there.
7
8use std::net::IpAddr;
9
10use clap::Subcommand;
11
12use super::style::Palette;
13use super::{CliError, resolve_profile};
14use crate::config::Config;
15use crate::filter::explain::{
16    Subject, explain, explanation_json, policy_json, render_explanation, render_policy,
17};
18use crate::sqlite::order::Identifier;
19
20#[derive(Subcommand)]
21pub enum FilterCommand {
22    /// Print the resolved access policy for a profile.
23    Show {
24        /// Which endpoint's policy. Optional when exactly one is configured.
25        #[arg(long)]
26        profile: Option<String>,
27        #[arg(long)]
28        json: bool,
29    },
30    /// Evaluate the policy against a hypothetical request.
31    ///
32    /// This really runs your `custom` scripts and really queries the
33    /// inventory, exactly as a request would. It touches no database and
34    /// creates nothing.
35    Explain {
36        #[arg(long)]
37        profile: Option<String>,
38        /// The address the request would come from.
39        #[arg(long)]
40        client_ip: Option<IpAddr>,
41        /// A name the request would ask for. Repeatable.
42        #[arg(long = "identifier")]
43        identifiers: Vec<String>,
44        /// The request path, for `path` checks.
45        #[arg(long, default_value = "/newOrder")]
46        path: String,
47        /// The account id the request would come from.
48        #[arg(long, default_value = "explain")]
49        account_id: String,
50        #[arg(long)]
51        json: bool,
52    },
53}
54
55pub async fn run_filter_command(
56    command: FilterCommand,
57    palette: Palette,
58    config: &Config,
59) -> Result<(), CliError> {
60    match command {
61        FilterCommand::Show { profile, json } => {
62            let (name, policy) = build(config, profile.as_deref())?;
63            if json {
64                print_json(&policy_json(&name, &policy))?;
65            } else {
66                print!("{}", render_policy(&name, &policy, palette));
67            }
68            Ok(())
69        }
70        FilterCommand::Explain {
71            profile,
72            client_ip,
73            identifiers,
74            path,
75            account_id,
76            json,
77        } => {
78            let (name, policy) = build(config, profile.as_deref())?;
79            let subject = Subject {
80                client_ip,
81                account_id,
82                identifiers: identifiers.iter().map(Identifier::dns).collect(),
83                path,
84                eab: None,
85            };
86
87            let explanation = explain(&policy, &subject).await;
88            if json {
89                print_json(&explanation_json(&name, &subject, &explanation))?;
90            } else {
91                print!(
92                    "{}",
93                    render_explanation(&name, &subject, &explanation, palette)
94                );
95            }
96            Ok(())
97        }
98    }
99}
100
101/// Both `--json` shapes, printed the one way.
102///
103/// Pretty rather than the compact `println!` [`crate::cli::render::print_rows`]
104/// uses: that one prints a *listing*, and these two print one object each — and
105/// the two subcommands of `filter` should not differ in how they hand it over.
106///
107/// Takes no [`Palette`], which is what keeps `--json` structurally out of
108/// colour's reach rather than merely out of its way.
109fn print_json(value: &serde_json::Value) -> Result<(), CliError> {
110    println!(
111        "{}",
112        serde_json::to_string_pretty(value)
113            .map_err(|error| CliError(format!("cannot render JSON: {error}")))?
114    );
115    Ok(())
116}
117
118/// Resolves the profile and builds its policy, exactly as startup would.
119///
120/// Building here rather than reading the configuration back means every
121/// startup refusal — an unknown check type, a condition that will not parse, a
122/// rule whose checks share no stage — is reported by this command too, which
123/// makes it the cheapest way to check a policy before restarting the server.
124///
125/// The inventory is deliberately **not** built: an `ipam` check would then need
126/// a reachable NetBox just to *print* the policy. The check is constructed with
127/// the same registry a running server would give it only when one is
128/// configured, and `explain` says so in its output when it reached outside.
129fn build(
130    config: &Config,
131    wanted: Option<&str>,
132) -> Result<(String, crate::filter::FilterPolicy), CliError> {
133    let profile = resolve_profile(config, wanted)?;
134    let sections = &profile.sections;
135
136    let resolver = crate::dns::HickoryResolver::from_system_uncached()
137        .map_err(|error| CliError(format!("cannot build a resolver: {error}")))?;
138    let proxies = crate::proxy::OutboundProxies::from_config(&config.proxy)
139        .map_err(|error| CliError(format!("configuration error: {error}")))?;
140
141    let inventory = crate::ipam::from_config(
142        &sections.ipam,
143        crate::http_client::Outbound::new(
144            std::sync::Arc::new(resolver),
145            std::sync::Arc::new(proxies),
146        ),
147    )
148    .map_err(|error| CliError(format!("profile `{}`: {error}", profile.name)))?;
149
150    let policy = crate::filter::build::build(
151        &sections.filter,
152        &config.dns,
153        inventory,
154        sections.eab.enabled,
155    )
156    .map_err(|error| CliError(format!("profile `{}`: {error}", profile.name)))?;
157
158    Ok((profile.name, policy))
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use crate::config::ENV_LOCK;
165
166    /// Loads a `Config` from TOML the way the server does, so
167    /// `resolve_profiles` has the raw sources per-key inheritance needs — the
168    /// same helper shape `cli::upstream`'s tests use, and for the same reason:
169    /// a `Config` deserialized directly carries no raw layer and resolves no
170    /// profiles at all.
171    fn load(body: &str) -> Config {
172        let _lock = ENV_LOCK
173            .lock()
174            .unwrap_or_else(std::sync::PoisonError::into_inner);
175        let dir = crate::testutil::TempDir::new("cli-filter");
176        std::fs::write(dir.join("config.toml"), body).unwrap();
177        // SAFETY: single-threaded test holding ENV_LOCK; removed before return.
178        unsafe {
179            std::env::set_var("ACME_PROXY_CONFIG", dir.join("config").to_str().unwrap());
180        }
181        let config = Config::load().expect("the configuration must load");
182        unsafe {
183            std::env::remove_var("ACME_PROXY_CONFIG");
184        }
185        config
186    }
187
188    const ONE_PROFILE: &str = r#"
189        [profiles.default]
190        [profiles.default.filter]
191        rules = ["mgmt"]
192        rule.mgmt.when = "net"
193        rule.mgmt.then = "allow"
194        check.net.type = "allowed_ip"
195        check.net.allow = ["10.0.0.0/8"]
196    "#;
197
198    async fn run(config: &Config, command: FilterCommand) -> Result<(), CliError> {
199        run_filter_command(command, Palette::plain(), config).await
200    }
201
202    #[tokio::test]
203    async fn show_prints_the_policy_of_the_only_profile() {
204        let config = load(ONE_PROFILE);
205        assert!(
206            run(
207                &config,
208                FilterCommand::Show {
209                    profile: None,
210                    json: false
211                }
212            )
213            .await
214            .is_ok()
215        );
216    }
217
218    #[tokio::test]
219    async fn show_accepts_the_profile_by_name() {
220        let config = load(ONE_PROFILE);
221        assert!(
222            run(
223                &config,
224                FilterCommand::Show {
225                    profile: Some("default".to_string()),
226                    json: false
227                }
228            )
229            .await
230            .is_ok()
231        );
232    }
233
234    #[tokio::test]
235    async fn an_unknown_profile_is_refused_by_name() {
236        let config = load(ONE_PROFILE);
237        let error = run(
238            &config,
239            FilterCommand::Show {
240                profile: Some("nope".to_string()),
241                json: false,
242            },
243        )
244        .await
245        .unwrap_err();
246        assert!(error.0.contains("no profile named `nope`"), "{}", error.0);
247    }
248
249    /// `--profile` is optional only when there is nothing to disambiguate,
250    /// matching `upstream show`.
251    #[tokio::test]
252    async fn several_profiles_require_naming_one() {
253        let config = load(
254            r#"
255            [profiles.a]
256            [profiles.b]
257            "#,
258        );
259        let error = run(
260            &config,
261            FilterCommand::Show {
262                profile: None,
263                json: false,
264            },
265        )
266        .await
267        .unwrap_err();
268        assert!(error.0.contains("--profile"), "{}", error.0);
269    }
270
271    /// The command builds the policy rather than reading it back, so every
272    /// startup refusal reaches an operator here too — which is the point of
273    /// having it.
274    #[tokio::test]
275    async fn a_broken_policy_is_reported_rather_than_printed() {
276        let config = load(
277            r#"
278            [profiles.default]
279            [profiles.default.filter]
280            rules = ["broken"]
281            rule.broken.when = "net and )"
282            rule.broken.then = "allow"
283            check.net.type = "allowed_ip"
284            check.net.allow = ["10.0.0.0/8"]
285            "#,
286        );
287        // Both shapes: the refusal comes out of `build` before the output
288        // branch, so a `--json` caller gets the error and not an empty
289        // document.
290        for json in [false, true] {
291            let error = run(
292                &config,
293                FilterCommand::Show {
294                    profile: None,
295                    json,
296                },
297            )
298            .await
299            .unwrap_err();
300            assert!(error.0.contains("at column"), "{}", error.0);
301        }
302    }
303
304    /// `show` answers in both shapes, the way `explain` already does.
305    #[tokio::test]
306    async fn show_runs_in_both_output_shapes() {
307        let config = load(ONE_PROFILE);
308        for json in [false, true] {
309            assert!(
310                run(
311                    &config,
312                    FilterCommand::Show {
313                        profile: None,
314                        json
315                    }
316                )
317                .await
318                .is_ok(),
319                "show --json={json} must succeed"
320            );
321        }
322    }
323
324    fn explain_of(profile: Option<String>, ip: &str, names: &[&str], json: bool) -> FilterCommand {
325        FilterCommand::Explain {
326            profile,
327            client_ip: Some(ip.parse().unwrap()),
328            identifiers: names.iter().map(std::string::ToString::to_string).collect(),
329            path: "/newOrder".to_string(),
330            account_id: "explain".to_string(),
331            json,
332        }
333    }
334
335    #[tokio::test]
336    async fn explain_runs_in_both_output_shapes() {
337        let config = load(ONE_PROFILE);
338        for json in [false, true] {
339            assert!(
340                run(
341                    &config,
342                    explain_of(None, "10.0.0.5", &["a.example.com"], json)
343                )
344                .await
345                .is_ok(),
346                "json = {json}"
347            );
348        }
349    }
350
351    #[tokio::test]
352    async fn explain_works_on_a_refused_address_too() {
353        let config = load(ONE_PROFILE);
354        assert!(
355            run(&config, explain_of(None, "203.0.113.9", &[], false))
356                .await
357                .is_ok()
358        );
359    }
360
361    /// An unconfigured policy explains rather than erroring: "no rules apply"
362    /// is the answer, and an operator checking a fresh install should see it.
363    #[tokio::test]
364    async fn explain_handles_a_policy_with_no_rules() {
365        let config = load("[profiles.default]\n");
366        assert!(
367            run(
368                &config,
369                explain_of(None, "10.0.0.5", &["a.example.com"], false)
370            )
371            .await
372            .is_ok()
373        );
374    }
375
376    #[tokio::test]
377    async fn explain_accepts_no_client_address_at_all() {
378        let config = load(ONE_PROFILE);
379        let command = FilterCommand::Explain {
380            profile: None,
381            client_ip: None,
382            identifiers: Vec::new(),
383            path: "/directory".to_string(),
384            account_id: "explain".to_string(),
385            json: false,
386        };
387        assert!(run(&config, command).await.is_ok());
388    }
389}