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 [`acme_proxy_policy::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::{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    /// Print the resolved access policy for a profile.
26    Show {
27        /// Which endpoint's policy. Optional when exactly one is configured.
28        #[arg(long)]
29        profile: Option<String>,
30        /// Print it as JSON.
31        #[arg(long)]
32        json: bool,
33    },
34    /// Evaluate the policy against a hypothetical request.
35    ///
36    /// This really runs your `custom` scripts and really queries the
37    /// inventory, exactly as a request would. It writes nothing, but like every
38    /// command but `serve` it needs a database that is migrated.
39    Explain {
40        /// Which endpoint's policy. Optional when exactly one is configured.
41        #[arg(long)]
42        profile: Option<String>,
43        /// The address the request would come from.
44        #[arg(long)]
45        client_ip: Option<IpAddr>,
46        /// A name the request would ask for. Repeatable.
47        #[arg(long = "identifier")]
48        identifiers: Vec<String>,
49        /// The request path, for `path` checks.
50        #[arg(long, default_value = "/newOrder")]
51        path: String,
52        /// The account id the request would come from.
53        #[arg(long, default_value = "explain")]
54        account_id: String,
55        /// Print it as JSON.
56        #[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
107/// Both `--json` shapes, printed the one way.
108///
109/// Pretty rather than the compact `println!` [`crate::cli::render::print_page`]
110/// uses: that one prints a *listing*, and these two print one object each — and
111/// the two subcommands of `filter` should not differ in how they hand it over.
112///
113/// Takes no [`Palette`], which is what keeps `--json` structurally out of
114/// colour's reach rather than merely out of its way.
115fn 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
124/// Resolves the profile and builds its policy, exactly as startup would.
125///
126/// Building here rather than reading the configuration back means every
127/// startup refusal — an unknown check type, a condition that will not parse, a
128/// rule whose checks share no stage — is reported by this command too, which
129/// makes it the cheapest way to check a policy before restarting the server.
130///
131/// The inventory is deliberately **not** built: an `ipam` check would then need
132/// a reachable NetBox just to *print* the policy. The check is constructed with
133/// the same registry a running server would give it only when one is
134/// configured, and `explain` says so in its output when it reached outside.
135fn 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        &sections.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        &sections.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    /// Loads a `Config` from TOML the way the server does, so
173    /// `resolve_profiles` has the raw sources per-key inheritance needs — the
174    /// same helper shape `cli::upstream`'s tests use, and for the same reason:
175    /// a `Config` deserialized directly carries no raw layer and resolves no
176    /// profiles at all.
177    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        // SAFETY: single-threaded test holding ENV_LOCK; removed before return.
184        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    /// `--profile` is optional only when there is nothing to disambiguate,
260    /// matching `upstream show`.
261    #[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    /// The command builds the policy rather than reading it back, so every
282    /// startup refusal reaches an operator here too — which is the point of
283    /// having it.
284    #[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        // Both shapes: the refusal comes out of `build` before the output
298        // branch, so a `--json` caller gets the error and not an empty
299        // document.
300        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    /// `show` answers in both shapes, the way `explain` already does.
315    #[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    /// An unconfigured policy explains rather than erroring: "no rules apply"
372    /// is the answer, and an operator checking a fresh install should see it.
373    #[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}