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`], so the panel could serve the same output
5//! later without moving any logic. Why it does not today is recorded there.
6
7use 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    /// Print the resolved access policy for a profile.
22    Show {
23        /// Which endpoint's policy. Optional when exactly one is configured.
24        #[arg(long)]
25        profile: Option<String>,
26    },
27    /// Evaluate the policy against a hypothetical request.
28    ///
29    /// This really runs your `custom` scripts and really queries the
30    /// inventory, exactly as a request would. It touches no database and
31    /// creates nothing.
32    Explain {
33        #[arg(long)]
34        profile: Option<String>,
35        /// The address the request would come from.
36        #[arg(long)]
37        client_ip: Option<IpAddr>,
38        /// A name the request would ask for. Repeatable.
39        #[arg(long = "identifier")]
40        identifiers: Vec<String>,
41        /// The request path, for `path` checks.
42        #[arg(long, default_value = "/newOrder")]
43        path: String,
44        /// The account id the request would come from.
45        #[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
99/// Resolves the profile and builds its policy, exactly as startup would.
100///
101/// Building here rather than reading the configuration back means every
102/// startup refusal — an unknown check type, a condition that will not parse, a
103/// rule whose checks share no stage — is reported by this command too, which
104/// makes it the cheapest way to check a policy before restarting the server.
105///
106/// The inventory is deliberately **not** built: an `ipam` check would then need
107/// a reachable NetBox just to *print* the policy. The check is constructed with
108/// the same registry a running server would give it only when one is
109/// configured, and `explain` says so in its output when it reached outside.
110fn 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        &sections.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        &sections.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    /// Loads a `Config` from TOML the way the server does, so
148    /// `resolve_profiles` has the raw sources per-key inheritance needs — the
149    /// same helper shape `cli::upstream`'s tests use, and for the same reason:
150    /// a `Config` deserialized directly carries no raw layer and resolves no
151    /// profiles at all.
152    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        // SAFETY: single-threaded test holding ENV_LOCK; removed before return.
159        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    /// `--profile` is optional only when there is nothing to disambiguate,
223    /// matching `upstream show`.
224    #[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    /// The command builds the policy rather than reading it back, so every
239    /// startup refusal reaches an operator here too — which is the point of
240    /// having it.
241    #[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    /// An unconfigured policy explains rather than erroring: "no rules apply"
298    /// is the answer, and an operator checking a fresh install should see it.
299    #[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}