Skip to main content

acme_proxy/filter/
explain.rs

1//! Rendering a policy, and what it would do to one hypothetical request.
2//!
3//! Everything `acme-proxy filter show` and `acme-proxy filter explain` print
4//! lives here; [`crate::cli::filter`] marshals arguments and does nothing else.
5//! That split is the one the rest of the admin surface uses, and it is what
6//! would let the web panel serve this later without moving any logic — see the
7//! warning below for why it does not today.
8//!
9//! ## `explain` really runs the policy
10//!
11//! It executes the operator's `custom` scripts and issues real IPAM and DNS
12//! requests, exactly as a request would, because a stubbed answer would be
13//! worse than nothing the first time it disagreed with production. It touches
14//! no database and creates nothing. [`Explanation::side_effects`] names the
15//! checks that reached outside the process, so the output says which parts of
16//! it were not free.
17//!
18//! That is also why this is a host-only command. The inputs — a client address
19//! and a list of names — are chosen by the caller, so behind an admin session
20//! it would be script execution and outbound requests driven from one stolen
21//! cookie, on a listener that deliberately carries no filter chain and no
22//! admission control.
23
24use std::fmt::Write as _;
25use std::net::IpAddr;
26
27use serde_json::{Value, json};
28
29use super::policy::{Evaluation, FilterPolicy, Outcome, Stage, Verdict};
30use super::{ConnectionContext, EabIdentity, IdentifierContext, IdentifierStage};
31use crate::cli::style::Palette;
32use crate::sqlite::order::Identifier;
33
34/// Which check kinds reach outside this process when they run.
35///
36/// Used only to warn, so an over-broad answer is the safe direction: a check
37/// listed here that happens not to have made a request this time is a harmless
38/// caution, where an omission would be a lie.
39const REACHES_OUT: &[&str] = &["custom", "ipam", "reverse_dns"];
40
41/// The request `explain` is asked about.
42#[derive(Debug, Clone, Default)]
43pub struct Subject {
44    pub client_ip: Option<IpAddr>,
45    pub account_id: String,
46    pub identifiers: Vec<Identifier>,
47    pub path: String,
48    pub eab: Option<EabIdentity>,
49}
50
51/// One stage's evaluation, plus the checks it never reached.
52#[derive(Debug)]
53pub struct StageReport {
54    pub label: &'static str,
55    pub evaluation: Evaluation,
56    /// Checks some applicable rule names that were never evaluated, because a
57    /// decisive operand short-circuited past them. Reporting these is half the
58    /// diagnostic value: "why did my `ipam` check not run" is otherwise
59    /// invisible, since a skipped check and a passing one look identical in
60    /// the outcome.
61    pub skipped: Vec<String>,
62    /// The HTTP answer this stage would produce.
63    pub answer: &'static str,
64}
65
66/// What the whole request would get.
67#[derive(Debug)]
68pub struct Explanation {
69    pub stages: Vec<StageReport>,
70    /// Names of the checks that reached outside the process while explaining.
71    pub side_effects: Vec<String>,
72}
73
74impl Explanation {
75    /// Whether every stage allowed. Both must, which is the thing operators
76    /// most often misread, so the rendering states it explicitly.
77    #[must_use]
78    pub fn allowed(&self) -> bool {
79        self.stages
80            .iter()
81            .all(|stage| matches!(stage.evaluation.outcome, Outcome::Allow))
82    }
83}
84
85/// Evaluates the policy at the connection stage and both identifier
86/// sub-stages, keeping every trace.
87pub async fn explain(policy: &FilterPolicy, subject: &Subject) -> Explanation {
88    let mut stages = Vec::new();
89
90    let connection = policy
91        .evaluate_connection(&ConnectionContext {
92            client_ip: subject.client_ip,
93            method: &axum::http::Method::POST,
94            path: &subject.path,
95        })
96        .await;
97    stages.push(report("connection", connection, policy, Stage::Connection));
98
99    for (label, sub_stage) in [
100        ("newOrder", IdentifierStage::NewOrder),
101        ("CSR", IdentifierStage::Csr),
102    ] {
103        let evaluation = policy
104            .evaluate_identifiers(&IdentifierContext {
105                client_ip: subject.client_ip,
106                account_id: &subject.account_id,
107                stage: sub_stage,
108                identifiers: &subject.identifiers,
109                eab: subject.eab.clone(),
110            })
111            .await;
112        stages.push(report(label, evaluation, policy, Stage::Identifiers));
113    }
114
115    let mut side_effects: Vec<String> = stages
116        .iter()
117        .flat_map(|stage| stage.evaluation.checks.iter())
118        .filter(|outcome| REACHES_OUT.contains(&outcome.kind))
119        .map(|outcome| outcome.name.clone())
120        .collect();
121    side_effects.sort_unstable();
122    side_effects.dedup();
123
124    Explanation {
125        stages,
126        side_effects,
127    }
128}
129
130/// Works out which checks the evaluation never reached, and the HTTP answer.
131fn report(
132    label: &'static str,
133    evaluation: Evaluation,
134    policy: &FilterPolicy,
135    stage: Stage,
136) -> StageReport {
137    let evaluated: Vec<&str> = evaluation
138        .checks
139        .iter()
140        .map(|outcome| outcome.name.as_str())
141        .collect();
142
143    let mut skipped: Vec<String> = policy
144        .rules()
145        .iter()
146        .filter(|rule| rule.stages.contains(stage))
147        .flat_map(|rule| rule.when.check_names())
148        .filter(|name| !evaluated.contains(name))
149        .map(std::string::ToString::to_string)
150        .collect();
151    skipped.sort_unstable();
152    skipped.dedup();
153
154    let answer = match (&evaluation.outcome, label) {
155        (Outcome::Allow, _) => "allowed",
156        (Outcome::Deny(_), "connection") => "403 access_denied",
157        (Outcome::Deny(_), "newOrder") => "403 rejectedIdentifier",
158        (Outcome::Deny(_), _) => "400 badCSR",
159        (Outcome::Undecided(_), _) => "500 serverInternal",
160    };
161
162    StageReport {
163        label,
164        evaluation,
165        skipped,
166        answer,
167    }
168}
169
170/// The resolved policy, as `filter show` prints it.
171///
172/// Conditions are re-printed through [`Condition`](super::expr::Condition)'s
173/// `Display`, which makes every grouping explicit — so an operator who wrote
174/// `a or b and c` sees `a or (b and c)` and has their answer about precedence.
175#[must_use]
176pub fn render_policy(profile: &str, policy: &FilterPolicy, palette: Palette) -> String {
177    let mut out = String::new();
178    let _ = writeln!(out, "profile: {profile}");
179
180    if !policy.is_active() {
181        let _ = writeln!(
182            out,
183            "\n{}",
184            palette.warn(
185                "no rules configured: this endpoint filters nothing, and any client that \
186                 can reach it may request a certificate for any name"
187            )
188        );
189        return out;
190    }
191
192    let _ = writeln!(
193        out,
194        "default: {} (when a rule was applicable and none matched)",
195        palette.status(policy.default_effect().as_str())
196    );
197
198    let _ = writeln!(out, "\nchecks");
199    for check in policy.checks() {
200        let _ = writeln!(
201            out,
202            "  {:<20} {:<12} {}",
203            check.name, check.kind, check.stages
204        );
205    }
206
207    let _ = writeln!(out, "\nrules (first match wins)");
208    for rule in policy.rules() {
209        let mode = match rule.mode {
210            super::Mode::Enforce => String::new(),
211            super::Mode::Warn => palette.warn("  [warn: matches but does not decide]"),
212        };
213        let _ = writeln!(
214            out,
215            "  {:<20} {} -> {}{}",
216            rule.name,
217            rule.when,
218            palette.status(rule.then.as_str()),
219            mode
220        );
221        let _ = writeln!(out, "  {:<20}   evaluated at: {}", "", rule.stages);
222    }
223
224    out
225}
226
227/// The human rendering of [`explain`].
228#[must_use]
229pub fn render_explanation(
230    profile: &str,
231    subject: &Subject,
232    explanation: &Explanation,
233    palette: Palette,
234) -> String {
235    let mut out = String::new();
236    let _ = writeln!(out, "profile: {profile}");
237    let _ = writeln!(
238        out,
239        "client:  {}",
240        subject
241            .client_ip
242            .map_or_else(|| "(none)".to_string(), |ip| ip.to_string())
243    );
244    let _ = writeln!(out, "path:    {}", subject.path);
245    if subject.identifiers.is_empty() {
246        let _ = writeln!(out, "names:   (none)");
247    } else {
248        let names: Vec<&str> = subject
249            .identifiers
250            .iter()
251            .map(|identifier| identifier.value.as_str())
252            .collect();
253        let _ = writeln!(out, "names:   {}", names.join(", "));
254    }
255
256    for stage in &explanation.stages {
257        let _ = writeln!(out, "\n{} stage", stage.label);
258
259        if stage.evaluation.checks.is_empty() && stage.skipped.is_empty() {
260            let _ = writeln!(out, "  no rule applies here, so it allows");
261        }
262
263        for outcome in &stage.evaluation.checks {
264            let (verdict, reason) = match &outcome.verdict {
265                Verdict::Pass => (palette.ok("pass"), String::new()),
266                Verdict::Fail(detail) => (palette.bad("fail"), format!("  {detail}")),
267                Verdict::Undecided(detail) => (palette.unknown("unknown"), format!("  {detail}")),
268            };
269            let _ = writeln!(
270                out,
271                "  {:<20} {:<12} {}{}",
272                outcome.name, outcome.kind, verdict, reason
273            );
274        }
275        for name in &stage.skipped {
276            let _ = writeln!(
277                out,
278                "  {name:<20} {:<12} skipped (an earlier operand already decided)",
279                ""
280            );
281        }
282
283        for warned in &stage.evaluation.warned {
284            let _ = writeln!(
285                out,
286                "  rule {} matched in warn mode and would have {}",
287                warned.name,
288                warned.then.as_str()
289            );
290        }
291
292        match (&stage.evaluation.matched, &stage.evaluation.outcome) {
293            (Some(rule), _) => {
294                let _ = writeln!(out, "  rule {rule} matched");
295            }
296            (None, Outcome::Allow) if stage.evaluation.checks.is_empty() => {}
297            (None, _) => {
298                let _ = writeln!(out, "  no rule matched, so the default applies");
299            }
300        }
301
302        // Painted off the outcome rather than off `answer`, which is an HTTP
303        // status line (`403 rejectedIdentifier`) and not a status word — and
304        // the Kleene third value has to read apart from a refusal here, since
305        // telling those two apart is the whole reason `explain` exists.
306        let (answer, detail) = match &stage.evaluation.outcome {
307            Outcome::Allow => (palette.ok(stage.answer), String::new()),
308            Outcome::Deny(detail) => (palette.bad(stage.answer), format!("  {detail}")),
309            Outcome::Undecided(detail) => (palette.unknown(stage.answer), format!("  {detail}")),
310        };
311        let _ = writeln!(out, "  -> {answer}{detail}");
312    }
313
314    let _ = writeln!(
315        out,
316        "\nresult: {}",
317        if explanation.allowed() {
318            palette.ok("allowed (every stage must allow, and every stage did)")
319        } else {
320            palette.bad("refused (a request is served only when every stage allows)")
321        }
322    );
323
324    if !explanation.side_effects.is_empty() {
325        let _ = writeln!(
326            out,
327            "\n{}",
328            palette.warn(&format!(
329                "note: these checks really ran, reaching outside this process exactly as a \
330                 request would: {}",
331                explanation.side_effects.join(", ")
332            ))
333        );
334    }
335
336    out
337}
338
339/// The `--json` rendering of [`explain`].
340#[must_use]
341pub fn explanation_json(profile: &str, subject: &Subject, explanation: &Explanation) -> Value {
342    let stages: Vec<Value> = explanation
343        .stages
344        .iter()
345        .map(|stage| {
346            let checks: Vec<Value> = stage
347                .evaluation
348                .checks
349                .iter()
350                .map(|outcome| {
351                    let (verdict, detail) = match &outcome.verdict {
352                        Verdict::Pass => ("pass", None),
353                        Verdict::Fail(detail) => ("fail", Some(detail.clone())),
354                        Verdict::Undecided(detail) => ("unknown", Some(detail.clone())),
355                    };
356                    json!({
357                        "name": outcome.name,
358                        "type": outcome.kind,
359                        "verdict": verdict,
360                        "detail": detail,
361                    })
362                })
363                .collect();
364
365            json!({
366                "stage": stage.label,
367                "checks": checks,
368                "skipped": stage.skipped,
369                "matchedRule": stage.evaluation.matched,
370                "warned": stage.evaluation.warned.iter().map(|warned| json!({
371                    "rule": warned.name,
372                    "wouldHave": warned.then.as_str(),
373                })).collect::<Vec<_>>(),
374                "answer": stage.answer,
375            })
376        })
377        .collect();
378
379    json!({
380        "profile": profile,
381        "request": {
382            "clientIp": subject.client_ip.map(|ip| ip.to_string()),
383            "path": subject.path,
384            "identifiers": subject.identifiers,
385            "accountId": subject.account_id,
386        },
387        "stages": stages,
388        "allowed": explanation.allowed(),
389        "sideEffects": explanation.side_effects,
390    })
391}
392
393#[cfg(test)]
394mod tests {
395    use std::sync::Arc;
396
397    use super::*;
398    use crate::filter::expr::Condition;
399    use crate::filter::policy::{Check, Effect, Mode, Rule};
400    use crate::filter::{ProxyPolicy, ip_allow};
401    use crate::testutil::dns_identifiers;
402
403    fn net(allow: &[&str]) -> Arc<dyn Check> {
404        Arc::new(
405            ip_allow::AllowedFromIpAddress::from_settings(
406                "net",
407                &ip_allow::Settings {
408                    allow: allow.iter().map(std::string::ToString::to_string).collect(),
409                    deny: Vec::new(),
410                },
411            )
412            .unwrap(),
413        )
414    }
415
416    fn names(allow: &[&str]) -> Arc<dyn Check> {
417        Arc::new(
418            crate::filter::identifiers::IdentifierList::from_settings(
419                "names",
420                &crate::filter::identifiers::Settings {
421                    allow: allow.iter().map(std::string::ToString::to_string).collect(),
422                    ..crate::filter::identifiers::Settings::default()
423                },
424            )
425            .unwrap(),
426        )
427    }
428
429    fn rule(name: &str, when: &str, then: Effect, mode: Mode) -> Rule {
430        Rule {
431            name: name.to_string(),
432            when: Condition::parse(when).unwrap(),
433            then,
434            message: None,
435            mode,
436        }
437    }
438
439    fn subject(ip: &str, names: &[&str]) -> Subject {
440        Subject {
441            client_ip: Some(ip.parse().unwrap()),
442            account_id: "explain".to_string(),
443            identifiers: dns_identifiers(names),
444            path: "/newOrder".to_string(),
445            eab: None,
446        }
447    }
448
449    fn policy() -> FilterPolicy {
450        FilterPolicy::new(
451            vec![
452                ("net".to_string(), net(&["10.0.0.0/8"])),
453                ("names".to_string(), names(&["*.example.com"])),
454            ],
455            vec![
456                rule("mgmt", "net", Effect::Allow, Mode::Enforce),
457                rule("corp", "names", Effect::Allow, Mode::Enforce),
458            ],
459            Effect::Deny,
460            ProxyPolicy::default(),
461        )
462    }
463
464    #[tokio::test]
465    async fn a_permitted_request_reports_every_stage_allowing() {
466        let policy = policy();
467        let subject = subject("10.0.0.5", &["web.example.com"]);
468        let explanation = explain(&policy, &subject).await;
469
470        assert!(explanation.allowed());
471        assert_eq!(explanation.stages.len(), 3);
472        let labels: Vec<&str> = explanation.stages.iter().map(|s| s.label).collect();
473        assert_eq!(labels, vec!["connection", "newOrder", "CSR"]);
474
475        let rendered = render_explanation("default", &subject, &explanation, Palette::plain());
476        assert!(rendered.contains("rule mgmt matched"), "{rendered}");
477        assert!(rendered.contains("-> allowed"), "{rendered}");
478        assert!(rendered.contains("every stage must allow"), "{rendered}");
479    }
480
481    /// The mapping an operator most wants: which HTTP answer each stage
482    /// produces, and that they are not the same one.
483    #[tokio::test]
484    async fn each_stage_names_its_own_http_answer() {
485        let policy = policy();
486        let subject = subject("203.0.113.9", &["web.evil.net"]);
487        let explanation = explain(&policy, &subject).await;
488
489        assert!(!explanation.allowed());
490        let answers: Vec<&str> = explanation.stages.iter().map(|s| s.answer).collect();
491        assert_eq!(
492            answers,
493            vec!["403 access_denied", "403 rejectedIdentifier", "400 badCSR"]
494        );
495
496        let rendered = render_explanation("default", &subject, &explanation, Palette::plain());
497        assert!(rendered.contains("no rule matched"), "{rendered}");
498        assert!(rendered.contains("refused"), "{rendered}");
499    }
500
501    /// A skipped check and a passing one look identical in the outcome, so
502    /// reporting the skip is the only way the output can answer "why did my
503    /// inventory check not run".
504    #[tokio::test]
505    async fn a_short_circuited_check_is_reported_as_skipped() {
506        let policy = FilterPolicy::new(
507            vec![
508                ("net".to_string(), net(&["10.0.0.0/8"])),
509                ("names".to_string(), names(&["*.example.com"])),
510            ],
511            // `net` passes, so the `or` never reaches `names`.
512            vec![rule("either", "net or names", Effect::Allow, Mode::Enforce)],
513            Effect::Deny,
514            ProxyPolicy::default(),
515        );
516
517        let subject = subject("10.0.0.5", &["web.evil.net"]);
518        let explanation = explain(&policy, &subject).await;
519
520        let identifiers = &explanation.stages[1];
521        assert_eq!(identifiers.skipped, vec!["names".to_string()]);
522        let rendered = render_explanation("default", &subject, &explanation, Palette::plain());
523        assert!(
524            rendered.contains("skipped (an earlier operand"),
525            "{rendered}"
526        );
527    }
528
529    #[tokio::test]
530    async fn a_warn_rule_is_rendered_as_what_it_would_have_done() {
531        let policy = FilterPolicy::new(
532            vec![("net".to_string(), net(&["10.0.0.0/8"]))],
533            vec![rule("would-deny", "net", Effect::Deny, Mode::Warn)],
534            Effect::Allow,
535            ProxyPolicy::default(),
536        );
537
538        let subject = subject("10.0.0.5", &[]);
539        let explanation = explain(&policy, &subject).await;
540        assert!(explanation.allowed());
541
542        let rendered = render_explanation("default", &subject, &explanation, Palette::plain());
543        assert!(
544            rendered.contains("matched in warn mode and would have deny"),
545            "{rendered}"
546        );
547    }
548
549    #[tokio::test]
550    async fn a_stage_with_no_rules_says_so_rather_than_looking_empty() {
551        let policy = FilterPolicy::new(
552            vec![("names".to_string(), names(&["*.example.com"]))],
553            vec![rule("corp", "names", Effect::Allow, Mode::Enforce)],
554            Effect::Deny,
555            ProxyPolicy::default(),
556        );
557
558        let subject = subject("10.0.0.5", &["web.example.com"]);
559        let rendered = render_explanation(
560            "default",
561            &subject,
562            &explain(&policy, &subject).await,
563            Palette::plain(),
564        );
565        assert!(rendered.contains("no rule applies here"), "{rendered}");
566    }
567
568    #[tokio::test]
569    async fn checks_that_reach_outside_the_process_are_named() {
570        let dir = crate::testutil::TempDir::new("filter-explain");
571        let script = crate::testutil::write_script(&dir, "hook.sh", "#!/bin/sh\nexit 0\n");
572        let hook: Arc<dyn Check> = Arc::new(
573            crate::filter::custom::CustomScriptFilter::from_settings(
574                "hook",
575                &crate::filter::custom::Settings {
576                    script_path: script.to_string_lossy().into_owned(),
577                    ..crate::filter::custom::Settings::default()
578                },
579            )
580            .unwrap(),
581        );
582
583        let policy = FilterPolicy::new(
584            vec![("hook".to_string(), hook)],
585            vec![rule("scripted", "hook", Effect::Allow, Mode::Enforce)],
586            Effect::Deny,
587            ProxyPolicy::default(),
588        );
589
590        let subject = subject("10.0.0.5", &["web.example.com"]);
591        let explanation = explain(&policy, &subject).await;
592        assert_eq!(explanation.side_effects, vec!["hook".to_string()]);
593
594        let rendered = render_explanation("default", &subject, &explanation, Palette::plain());
595        assert!(rendered.contains("these checks really ran"), "{rendered}");
596    }
597
598    #[tokio::test]
599    async fn the_json_shape_carries_every_stage_and_its_verdicts() {
600        let policy = policy();
601        let subject = subject("203.0.113.9", &["web.evil.net"]);
602        let explanation = explain(&policy, &subject).await;
603        let value = explanation_json("default", &subject, &explanation);
604
605        assert_eq!(value["profile"], "default");
606        assert_eq!(value["allowed"], false);
607        assert_eq!(value["request"]["clientIp"], "203.0.113.9");
608        let stages = value["stages"].as_array().unwrap();
609        assert_eq!(stages.len(), 3);
610        assert_eq!(stages[0]["stage"], "connection");
611        assert_eq!(stages[0]["answer"], "403 access_denied");
612        assert_eq!(stages[0]["checks"][0]["verdict"], "fail");
613        assert!(
614            stages[0]["checks"][0]["detail"]
615                .as_str()
616                .unwrap()
617                .contains("not allowed")
618        );
619    }
620
621    /// `show` re-prints conditions with grouping made explicit, which is the
622    /// whole reason it exists rather than an operator re-reading their file.
623    #[test]
624    fn show_renders_the_policy_with_explicit_grouping() {
625        let policy = FilterPolicy::new(
626            vec![
627                ("net".to_string(), net(&["10.0.0.0/8"])),
628                ("names".to_string(), names(&["*.example.com"])),
629            ],
630            vec![rule(
631                "mixed",
632                "names or net and names",
633                Effect::Allow,
634                Mode::Enforce,
635            )],
636            Effect::Deny,
637            ProxyPolicy::default(),
638        );
639
640        let rendered = render_policy("default", &policy, Palette::plain());
641        assert!(rendered.contains("names or (net and names)"), "{rendered}");
642        assert!(rendered.contains("net"), "{rendered}");
643        assert!(rendered.contains("allowed_ip"), "{rendered}");
644        assert!(rendered.contains("default: deny"), "{rendered}");
645        assert!(rendered.contains("identifiers only"), "{rendered}");
646    }
647
648    #[test]
649    fn show_says_plainly_when_nothing_is_configured() {
650        let rendered = render_policy("default", &FilterPolicy::default(), Palette::plain());
651        assert!(rendered.contains("filters nothing"), "{rendered}");
652    }
653
654    #[test]
655    fn show_marks_a_warn_rule() {
656        let policy = FilterPolicy::new(
657            vec![("net".to_string(), net(&["10.0.0.0/8"]))],
658            vec![rule("dry", "net", Effect::Deny, Mode::Warn)],
659            Effect::Deny,
660            ProxyPolicy::default(),
661        );
662        assert!(render_policy("default", &policy, Palette::plain()).contains("does not decide"));
663    }
664}