1use 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
34const REACHES_OUT: &[&str] = &["custom", "ipam", "reverse_dns"];
40
41#[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#[derive(Debug)]
53pub struct StageReport {
54 pub label: &'static str,
55 pub evaluation: Evaluation,
56 pub skipped: Vec<String>,
62 pub answer: &'static str,
64}
65
66#[derive(Debug)]
68pub struct Explanation {
69 pub stages: Vec<StageReport>,
70 pub side_effects: Vec<String>,
72}
73
74impl Explanation {
75 #[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
85pub 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
130fn 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#[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#[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 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#[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 #[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 #[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 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 #[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}