tor_netdoc/types/policy/summary.rs
1//! Precise IP and port policy summarisation algorithm
2//!
3//! We don't use macrology for v4/v6, instead writing things twice.
4//! We don't fear copypasta errors because the type system almost
5//! always prevents mixing v4 and v6 information.
6//!
7//! We're using [`iprange::IpRange`] for our IP address sets.
8//! That *is* a trie, but it's a pretty unoptimised one:
9//! every node is fully boxed and there is no layer elision.
10//! But it *does* have a nice API.
11//!
12//! See [`Summariser`] for the algorithm.
13
14use std::net::{Ipv4Addr, Ipv6Addr};
15
16use derive_deftly::{Deftly, define_derive_deftly};
17use ipnet::{IpNet, Ipv4Net, Ipv6Net};
18use iprange::IpRange;
19use rangemap::RangeInclusiveMap;
20use void::{ResultVoidExt as _, Void};
21
22use crate::rangemap_mutate_range;
23
24use super::*;
25
26//---------- support materials ----------
27
28/// Ports are 16-bit. Alias for clarity.
29type Port = u16;
30
31/// Range for all real ports (not zero)
32const ALL_PORTS: RangeInclusive<Port> = 1..=u16::MAX;
33
34/// `eprintln` but in tests only, and prefix with `"TPRINT "`
35///
36/// Called in the non-test code in various places, but elided other than actually in tests.
37macro_rules! tprintln { { $($a:tt)* } => { { {
38 #[cfg(test)]
39 eprintln!("TPRINT {}", format_args!($($a)*));
40} } } }
41
42//---------- IpNet trait for dealing with IP version generically ----------
43
44/// `Ipv4Net` or `Ipv6Net` - IP-version specific handling
45trait Net: iprange::IpNet {
46 /// How many bits?
47 const ADDR_BITS: u8;
48
49 /// Return a /0 netblock.
50 fn all() -> Self;
51
52 /// How many hosts in this netblock?
53 ///
54 /// If the answer is 2^128, gives 2^128-1 instead.
55 fn host_count_saturating(&self) -> u128 {
56 let shift = Self::ADDR_BITS - self.prefix_len();
57 1_u128.checked_shl(shift.into()).unwrap_or(u128::MAX)
58 }
59}
60
61impl Net for Ipv4Net {
62 const ADDR_BITS: u8 = 32;
63
64 fn all() -> Self {
65 Ipv4Net::new(Ipv4Addr::UNSPECIFIED, 0).expect("should be OK")
66 }
67}
68
69impl Net for Ipv6Net {
70 const ADDR_BITS: u8 = 128;
71
72 fn all() -> Self {
73 Ipv6Net::new(Ipv6Addr::UNSPECIFIED, 0).expect("should be OK")
74 }
75}
76
77//==================== principal algorithm ====================
78
79//---------- working data structure ----------
80
81/// State for summarisation algorithm
82///
83/// We walk the rules in *reverse order*.
84/// The rules are semantically first-match, but we want to walk *all* the rules,
85/// doing all the port updates in parallel, and updating the accept/reject state
86/// as we go - i.e. last match wins.
87#[derive(Debug)]
88struct Summariser {
89 /// Set of IP addresses we are rejecting for each port
90 ///
91 /// Invariants: port 0 is not in the map.
92 /// Every other port has an entry in the map, even if it's just two empty IpRanges.
93 reject: RangeInclusiveMap<Port, Rejects>,
94}
95
96/// Which V4 and V6 addresses we are rejecting for a particular port
97#[derive(Debug, Default, PartialEq, Clone)]
98struct Rejects {
99 /// Rejections for V4
100 v4: IpRange<Ipv4Net>,
101 /// Rejections for V6
102 v6: IpRange<Ipv6Net>,
103}
104
105//---------- data accumulation into Summariser ----------
106
107impl Summariser {
108 /// Start the summarisation algorithm
109 fn start() -> Self {
110 let mut reject = RangeInclusiveMap::new();
111 reject.insert(ALL_PORTS, Rejects::default());
112 Summariser { reject }
113 }
114
115 /// Apply `rule_kind` for `pat` (for all IP versions) to `self`
116 ///
117 /// Overwrites old information - so last update wins.
118 ///
119 /// Calls `Reject::apply_rule` once for every relevant combination of:
120 ///
121 /// * port range (via [`rangemap_mutate_range`])
122 /// * address family (open-coded, two similar calls)
123 fn apply_rule(&mut self, rule_kind: RuleKind, pat: &AddrPortPattern) {
124 let (v4, v6) = match pat.addrs {
125 IpPattern::All => (Some(Net::all()), Some(Net::all())),
126 IpPattern::Net(IpNet::V4(n)) => (Some(n), None),
127 IpPattern::Net(IpNet::V6(n)) => (None, Some(n)),
128 };
129
130 let ports = pat.ports.to_range();
131
132 tprintln!("apply_rule {ports:?} {rule_kind:?} {pat:?}");
133 rangemap_mutate_range(
134 &mut self.reject,
135 &ports,
136 |rejects: &mut Option<Rejects>, ports| {
137 let Some(rejects) = rejects else {
138 debug_assert_eq!(*ports, 0..=0);
139 return Ok(());
140 };
141 Rejects::apply_rule(&mut rejects.v4, rule_kind, v4, ports);
142 Rejects::apply_rule(&mut rejects.v6, rule_kind, v6, ports);
143 Ok::<_, Void>(())
144 },
145 )
146 .void_unwrap();
147 }
148}
149
150impl Rejects {
151 /// Apply `rule_kind` for `addrs` to `reject` for IP version `N`
152 fn apply_rule<N: Net>(
153 reject: &mut IpRange<N>,
154 rule_kind: RuleKind,
155 addrs: Option<N>,
156 #[cfg_attr(not(test), allow(unused))] // for debugging prints in tests, only
157 ports: &RangeInclusive<u16>,
158 ) {
159 let Some(addrs) = addrs else {
160 return;
161 };
162 match rule_kind {
163 RuleKind::Accept => reject.remove(addrs),
164 RuleKind::Reject => reject.add(addrs),
165 };
166 tprintln!("apply_rule {ports:?} {rule_kind:?} {addrs:?} now reject={reject:?}");
167 }
168}
169
170//---------- readout core ----------
171
172impl Summariser {
173 /// Calculate the summary policy for IP version `N`
174 ///
175 /// `select_rejects` should pick the corresponding field out of `Rejects`
176 fn policy_for_one_ip_version<N: Net>(
177 &self,
178 select_rejects: impl Fn(&Rejects) -> &IpRange<N>,
179 max_reject_count: u128,
180 ) -> PortPolicy {
181 let mut allowed = PortRanges::new();
182 for (ports, reject_ranges) in self.reject.iter() {
183 tprintln!(
184 "ports {:20} rej.count,max={max_reject_count:x}",
185 format!("{ports:?}"),
186 );
187 let outcome = 'outcome: {
188 let mut reject_count = 0_u128;
189 for net in select_rejects(reject_ranges) {
190 reject_count = reject_count.saturating_add(net.host_count_saturating());
191 tprintln!(
192 "ports {:20} rej.count,now={reject_count:x} including {net:?}",
193 format!("{ports:?}"),
194 );
195 if reject_count > max_reject_count {
196 break 'outcome RuleKind::Reject;
197 }
198 }
199 debug_assert!(reject_count <= max_reject_count);
200 break 'outcome RuleKind::Accept;
201 };
202 tprintln!("ports {:22} {outcome:?}", format!("{ports:?}"));
203 match outcome {
204 RuleKind::Accept => {
205 let ports =
206 PortRange::from_range(ports.clone()).expect("bad range in rangemap");
207 allowed
208 .push_ordered(ports)
209 .expect("disordered output from rangemap");
210 }
211 RuleKind::Reject => {}
212 }
213 }
214 PortPolicy::from_allowed_port_ranges(allowed)
215 }
216}
217
218define_derive_deftly! {
219 /// Define [`PortPolicies::from_summariser`].
220 //
221 // The `v4` and `v6` fields have the same type.
222 // Using a macro makes the otherwise-easy copy-pasta bugs impossible.
223 PortPolicies beta_deftly:
224
225 $impl {
226 /// Actually calculate the port policy summaries for both IP versions
227 fn from_summariser(
228 summariser: Summariser,
229 thresh: &PortSummaryThresholds,
230 ) -> PortPolicies {
231 PortPolicies { $(
232 $fname: summariser.policy_for_one_ip_version(|r| &r.$fname, thresh.$fname),
233 ) }
234 }
235 }
236}
237
238//==================== primary entrypoint, and output type ====================
239
240/// A pair of port policy summaries, one for IPv4 and one for IPv6
241///
242/// Returned by [`AddrPolicy::summarise_precise`].
243#[derive(Debug, Clone, Eq, PartialEq, Hash, Deftly)]
244#[derive_deftly(PortPolicies)]
245#[allow(clippy::exhaustive_structs)] // New IP version would be a breaking change
246pub struct PortPolicies {
247 /// IPv4
248 pub v4: PortPolicy,
249
250 /// IPv6
251 pub v6: PortPolicy,
252}
253
254impl AddrPolicy {
255 /// Calculate port policy summaries using a precise but unhardened algorithm
256 ///
257 /// Returns two Exit Policy Summaries, one for for each of IPv4 and IPv6.
258 /// <https://spec.torproject.org/dir-spec/computing-consensus.html#exit-summary>
259 ///
260 /// **Not generally suitable for use on untrusted input because
261 /// there is no effort to limit the computational complexity.**
262 ///
263 /// Useful for a router, when calculating
264 /// [`ipv6-policy`
265 /// ](https://spec.torproject.org/dir-spec/server-descriptor-format.html#item:ipv6-policy)
266 /// in its router descriptor, from its own (locally configured) accept/reject policy.
267 ///
268 /// The result is calculated according to
269 /// [this rule](https://spec.torproject.org/dir-spec/computing-consensus.html#exit-summary:semantics):
270 ///
271 /// > A port should be summarised as accepted iff the full exit policy
272 /// > permits “most” “public” addresses on that port.
273 ///
274 /// `summarise_precise` implements the rule precisely as specified there;
275 /// not the hardened approximate algorithm used by dirauths for IPv4 summaries.
276 ///
277 /// `private_ranges` is the ranges considered not "public".
278 /// Rejections of addresses in these ranges are disregarded when considering
279 /// whether a port is open.
280 ///
281 /// This algorithm does not handle "IPv4-mapped Addresses"
282 /// (ie, IPv6-mapped IPv4 addresses) specially.
283 /// They should normally be rejected, and be in `private_ranges`.
284 ///
285 /// `thresholds` should normally be `&PortSummaryThresholds::DEFAULT`.
286 //
287 // To generate an `ipv6-policy` line, it would be sufficient to only calculate a v6 summary.
288 // So why provide v4 too? Because it's useful for testing of the approximate summary
289 // algorithm, and because we might want to move v4 policy summarisation to relays, too.
290 //
291 // Why return both policies, rather than providing separate entrypoints?
292 // Mostly, because it's convenient in the implementation: computing them separately
293 // would mean more of our principal code would be generic over `N`.
294 // This is not supposed to be a hot path anyway.
295 pub fn summarise_precise(
296 &self,
297 thresholds: &PortSummaryThresholds,
298 private_ranges: impl IntoIterator<Item = IpNet>,
299 ) -> PortPolicies {
300 let mut s = Summariser::start();
301 for (rule_kind, pat) in self.rules().rev() {
302 s.apply_rule(rule_kind, &pat);
303 }
304
305 tprintln!("summariser intermediate: {s:#?}");
306
307 // We handle private ranges by deleting them from rejected list, pretending they're open
308
309 let all_ports =
310 PortRange::from_range(ALL_PORTS).expect("all ports is fixedly correct range");
311
312 for private in private_ranges {
313 s.apply_rule(
314 RuleKind::Accept,
315 &AddrPortPattern {
316 addrs: IpPattern::Net(private),
317 ports: all_ports,
318 },
319 );
320 }
321
322 tprintln!("summariser final: {s:#?}");
323
324 PortPolicies::from_summariser(s, thresholds)
325 }
326}
327
328//---------- PortSummaryThresholds configuration type ----------
329
330/// Thresholds for deciding whether a port counts as open, for a summary
331///
332/// Each value is the maximum number of individual addresses
333/// that may be blocked before the port is considered closed.
334///
335/// The `Default` implementation, and [`PortSummaryThresholds::DEFAULT`],
336/// provide the thresholds currently specified in torspec.
337///
338/// We provide this as a controllable parameter so that the summariser is
339/// a pure function that doesn't embed these tuneables.
340/// Then if the spec changes, the directory authority consensus calculator
341/// can provide the appropriate thresholds depending on the consensus method.
342#[derive(Debug, Clone, Eq, PartialEq, Hash, Deftly)]
343#[allow(clippy::exhaustive_structs)] // New IP version would be a breaking change
344#[derive_deftly(PortSummaryThresholds)]
345pub struct PortSummaryThresholds {
346 /// IPv4
347 ///
348 /// Currently, the spec says
349 ///
350 /// > no more than 2^25 IPv4 addresses (two /8's worth, or one /7's worth)
351 #[deftly(default_prefix_len = 7)]
352 pub v4: u128,
353
354 /// IPv6
355 ///
356 /// Currently, the spec says
357 ///
358 /// > no more than 2^112 IPv6 addresses (one /16's worth)
359 #[deftly(default_prefix_len = 16)]
360 pub v6: u128,
361}
362
363define_derive_deftly! {
364 /// Define impls on `PortSummaryThresholds`
365 ///
366 /// This is a macro because there's no type-based safeguard against copy-paste bugs.
367 PortSummaryThresholds beta_deftly, meta_quoted rigorous:
368
369 ${define N $<Ip $fname Net>}
370
371 $impl {
372 /// PortSummaryThresholds from prefix lengths
373 ///
374 /// Returns a `PortSummaryThresholds` whose thresholds are
375 /// "one /`v4`'s worth" for IPv4
376 /// and
377 /// "one /`v6`'s worth" for IPv6.
378 pub const fn from_prefix_lengths( $(
379 $<$fname _prefix_len>: u8,
380 ) ) -> PortSummaryThresholds {
381 PortSummaryThresholds { $(
382 $fname: 1_u128 << $N::ADDR_BITS - $<$fname _prefix_len>,
383 ) }
384 }
385
386 /// Default value, from the Tor Specifications
387 pub const DEFAULT: PortSummaryThresholds = PortSummaryThresholds::from_prefix_lengths( $(
388 ${fmeta(default_prefix_len) as expr},
389 ) );
390 }
391}
392use derive_deftly_template_PortSummaryThresholds;
393
394impl Default for PortSummaryThresholds {
395 fn default() -> Self {
396 PortSummaryThresholds::DEFAULT
397 }
398}
399
400#[cfg(test)]
401mod test {
402 // @@ begin test lint list maintained by maint/add_warning @@
403 #![allow(clippy::bool_assert_comparison)]
404 #![allow(clippy::clone_on_copy)]
405 #![allow(clippy::dbg_macro)]
406 #![allow(clippy::mixed_attributes_style)]
407 #![allow(clippy::print_stderr)]
408 #![allow(clippy::print_stdout)]
409 #![allow(clippy::single_char_pattern)]
410 #![allow(clippy::unwrap_used)]
411 #![allow(clippy::unchecked_time_subtraction)]
412 #![allow(clippy::useless_vec)]
413 #![allow(clippy::needless_pass_by_value)]
414 #![allow(clippy::string_slice)] // See arti#2571
415 //! <!-- @@ end test lint list maintained by maint/add_warning @@ -->
416 use super::*;
417 use crate::parse_testcase_from_netdoc;
418 use itertools::Itertools;
419
420 #[derive(Deftly)]
421 #[derive_deftly(NetdocParseableFields)]
422 struct TestCase {
423 /// Input policy, `accept` and `reject` lines
424 #[deftly(netdoc(flatten))]
425 full: AddrPolicy,
426
427 /// Expected IPv4 summary
428 p4: PortPolicy,
429
430 /// Expected IPv6 summary
431 p6: PortPolicy,
432 }
433
434 /// Run one test case
435 ///
436 /// This is the implementation of `chk`.
437 ///
438 /// It returns `Result`, just for the benefit of its self-test.
439 fn chk_inner(input_doc: &str) -> anyhow::Result<()> {
440 let case: TestCase = parse_testcase_from_netdoc(input_doc);
441
442 let summary = case.full.summarise_precise(
443 &PortSummaryThresholds::DEFAULT,
444 [
445 // hardly a complete list
446 "0.0.0.0/8",
447 "::/8",
448 "::faff:0:0/96",
449 "10.0.0.0/8",
450 "172.16.0.0/12",
451 "192.168.0.0/16",
452 "fd00::/8",
453 ]
454 .into_iter()
455 .map(|s| s.parse::<IpNet>().expect(s))
456 .collect_vec(),
457 );
458
459 /// Like `assert_eq` combined with `anyhow::ensure` - throws `Err(anyhow::Error)`
460 macro_rules! ensure_eq { { $a:expr, $b:expr } => {
461 anyhow::ensure!($a == $b, "{:?} != {:?}", $a, $b);
462 } }
463
464 ensure_eq!(summary.v4, case.p4);
465 ensure_eq!(summary.v6, case.p6);
466
467 Ok(())
468 }
469
470 /// Run one test case
471 ///
472 /// Test cases are strings in netdoc format, for `TestCase`,
473 /// but without the intro item.
474 ///
475 /// Whitespace will be normalised and `#`-comments stripped.
476 fn chk(input_doc: &str) {
477 chk_inner(input_doc).expect("test failed");
478 }
479
480 #[test]
481 fn basics() {
482 chk(r"
483 p4 accept 1-65535
484 p6 accept 1-65535
485 ");
486 chk(r"
487 accept *:*
488 p4 accept 1-65535
489 p6 accept 1-65535
490 ");
491 chk(r"
492 reject *:*
493 p4 reject 1-65535
494 p6 reject 1-65535
495 ");
496 chk(r"
497 reject 0.0.0.0/0:*
498 p4 reject 1-65535
499 p6 accept 1-65535
500 ");
501 chk(r"
502 reject [::]/0:*
503 p4 accept 1-65535
504 p6 reject 1-65535
505 ");
506 }
507
508 #[test]
509 fn edge_cases() {
510 // Reject nearly enough addresses to reject
511 let reject_precisely_allowed_amount = r"
512 accept *:100
513 reject 1.0.0.0/8:400-419
514 reject 2.0.0.0/8:410-429
515 reject [2002::]/17:600-619
516 reject [2003::]/17:610-629
517 reject 0.0.0.0/0:1-399
518 reject 0.0.0.0/0:430-65535
519 reject [::]/0:1-599
520 reject [::]/0:630-65535
521 ";
522
523 chk(&format!(
524 r" {reject_precisely_allowed_amount}
525
526 # reject some private nets, proving they are disregarded
527 reject 10.0.0.0/8:*
528 reject [fd00::]/8:*
529
530 p4 accept 100,400-429
531 p6 accept 100,600-629 "
532 ));
533
534 // Reject one more address
535 chk(&format!(
536 r" {reject_precisely_allowed_amount}
537
538 reject 4.0.0.0/32:415-425
539 reject [2001:a::1]/128:615-625
540
541 p4 accept 100,400-414,420-429
542 p6 accept 100,600-614,620-629 "
543 ));
544 }
545
546 #[test]
547 fn chk_detects_discrepancies() {
548 // The output is supposed to be `p4 reject 25` ...
549 let input_doc = r"
550 reject *:25
551 p4 reject 26
552 p6 reject 26
553 ";
554 let e = chk_inner(input_doc).expect_err("was supposed to fail");
555 let e = format!("{e:#}"); // # to make anyhow print sources
556
557 assert!(
558 e.contains("!= PortPolicy { allowed: PortRanges([PortRange(1-25)"),
559 "error: {e}"
560 );
561 }
562}