@push.rocks/smartnftables 2.5.2 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -115,6 +115,10 @@ pub struct HostGrant {
115
115
  pub destination_address: String,
116
116
  pub destination_port: u16,
117
117
  }
118
+ /// One stateful one-way flow between two workloads behind the same router: the
119
+ /// source workload dials one exact port of the destination workload, which may
120
+ /// only answer. It is the same exact tuple as a host grant.
121
+ pub type WorkloadGrant = HostGrant;
118
122
  #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
119
123
  #[serde(rename_all = "camelCase", deny_unknown_fields)]
120
124
  pub struct RouterScope {
@@ -131,6 +135,9 @@ pub struct RouterScope {
131
135
  /// Absent and empty are the same canonical policy, like publications.
132
136
  #[serde(default, skip_serializing_if = "Vec::is_empty")]
133
137
  pub host_grants: Vec<HostGrant>,
138
+ /// Absent and empty are the same canonical policy, like publications.
139
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
140
+ pub workload_grants: Vec<WorkloadGrant>,
134
141
  }
135
142
  #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
136
143
  #[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -161,6 +168,20 @@ pub struct HostScope {
161
168
  /// Absent and empty are the same canonical policy, like publications.
162
169
  #[serde(default, skip_serializing_if = "Vec::is_empty")]
163
170
  pub host_grants: Vec<HostGrant>,
171
+ /// Ids of platform endpoints this host serves on an address of one of its
172
+ /// bound links. Absent and empty are the same canonical policy.
173
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
174
+ pub local_platform_endpoints: Vec<String>,
175
+ }
176
+ /// One loopback TCP service that only one local user may dial: packets the host
177
+ /// sends to the exact address and port from a socket of any other user reject,
178
+ /// and packets arriving there on any interface but loopback drop.
179
+ #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
180
+ #[serde(rename_all = "camelCase", deny_unknown_fields)]
181
+ pub struct LocalTcpPortOwner {
182
+ pub address: String,
183
+ pub port: u16,
184
+ pub uid: u32,
164
185
  }
165
186
  #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
166
187
  #[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -171,6 +192,10 @@ pub struct AllocationPoolGuardScope {
171
192
  /// Absent and empty are the same canonical policy.
172
193
  #[serde(default, skip_serializing_if = "Vec::is_empty")]
173
194
  pub host_grants: Vec<HostGrant>,
195
+ /// Host-wide loopback port ownership. Absent and empty are the same
196
+ /// canonical policy.
197
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
198
+ pub local_tcp_port_owners: Vec<LocalTcpPortOwner>,
174
199
  }
175
200
  #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
176
201
  #[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)]
@@ -565,6 +590,46 @@ fn normalize_router_host_grants(value: &mut RouterScope) -> Result<()> {
565
590
  value.host_grants = grants;
566
591
  Ok(())
567
592
  }
593
+ /// Both ends are exact current addresses of two different veth workload endpoints
594
+ /// of this scope, neither router-local nor a platform endpoint. Endpoint prefixes
595
+ /// are protected, so both directions stay in the default conntrack zone ahead of
596
+ /// every leased classifier.
597
+ fn normalize_router_workload_grants(value: &mut RouterScope) -> Result<()> {
598
+ let mut grants = std::mem::take(&mut value.workload_grants);
599
+ let scope = &*value;
600
+ let workload = |ip: &str| -> Result<&policy::Endpoint> {
601
+ let target = format!("{ip}/32");
602
+ let endpoint = scope
603
+ .endpoints
604
+ .iter()
605
+ .find(|endpoint| {
606
+ endpoint
607
+ .source_prefixes
608
+ .iter()
609
+ .any(|source| covers(source, &target) == Ok(true))
610
+ })
611
+ .ok_or(Error::Invalid)?;
612
+ let local = scope
613
+ .links
614
+ .iter()
615
+ .chain(std::iter::once(&scope.handoff))
616
+ .any(|link| link.required_ipv4_addresses.iter().any(|item| item == ip));
617
+ let platform = scope
618
+ .protection
619
+ .platform_endpoints
620
+ .iter()
621
+ .any(|item| item.address == ip);
622
+ require(endpoint.interface_kind == "veth" && !local && !platform)?;
623
+ Ok(endpoint)
624
+ };
625
+ normalize_host_grants(&mut grants, |grant| {
626
+ let source = workload(&grant.source_address)?;
627
+ let destination = workload(&grant.destination_address)?;
628
+ require(source.id != destination.id)
629
+ })?;
630
+ value.workload_grants = grants;
631
+ Ok(())
632
+ }
568
633
  fn normalize_router(value: &mut RouterScope, revision: u64) -> Result<()> {
569
634
  require(value.links.len() <= 32 && value.generations.len() <= 32)?;
570
635
  let private = policy::Policy {
@@ -737,7 +802,8 @@ fn normalize_router(value: &mut RouterScope, revision: u64) -> Result<()> {
737
802
  )
738
803
  }))?;
739
804
  normalize_router_published(value)?;
740
- normalize_router_host_grants(value)
805
+ normalize_router_host_grants(value)?;
806
+ normalize_router_workload_grants(value)
741
807
  }
742
808
  fn normalize_pool_guard(value: &mut AllocationPoolGuardScope) -> Result<()> {
743
809
  require(
@@ -760,8 +826,32 @@ fn normalize_pool_guard(value: &mut AllocationPoolGuardScope) -> Result<()> {
760
826
  require(covers_address(&value.prefixes, &grant.destination_address)?)
761
827
  })?;
762
828
  value.host_grants = grants;
829
+ normalize_local_tcp_port_owners(&mut value.local_tcp_port_owners)
830
+ }
831
+ /// One exact IPv4 loopback host address and port per entry, never a prefix,
832
+ /// wildcard or range, each with exactly one owning uid. `(uid_t)-1` is not a
833
+ /// user; it is the kernel's "unchanged" sentinel.
834
+ fn normalize_local_tcp_port_owners(owners: &mut [LocalTcpPortOwner]) -> Result<()> {
835
+ require(owners.len() <= LOCAL_TCP_PORT_OWNERS)?;
836
+ owners.sort();
837
+ for (index, owner) in owners.iter().enumerate() {
838
+ let ip = address(&owner.address)?;
839
+ require(
840
+ ip >> 24 == 127
841
+ && ip != 0x7f00_0000
842
+ && ip != 0x7fff_ffff
843
+ && owner.port > 0
844
+ && owner.uid != u32::MAX
845
+ && owners[..index]
846
+ .iter()
847
+ .all(|other| other.address != owner.address || other.port != owner.port),
848
+ )?;
849
+ }
763
850
  Ok(())
764
851
  }
852
+ /// Two rules per entry; the maximum fits the atomic graph beside a guard with
853
+ /// 64 pools and 1024 host grants.
854
+ const LOCAL_TCP_PORT_OWNERS: usize = 8;
765
855
  /// The host dials from an exact current address of exactly one handoff link, into
766
856
  /// protected space that is neither host-local, a platform endpoint nor a leased
767
857
  /// router transit address: only a workload behind that handoff remains.
@@ -878,14 +968,31 @@ fn normalize_host(value: &mut HostScope) -> Result<()> {
878
968
  .flat_map(|item| item.link.required_ipv4_addresses.iter().cloned())
879
969
  .chain(value.uplink.required_ipv4_addresses.iter().cloned())
880
970
  .collect();
971
+ // A platform endpoint on a host address is served here, not forwarded, so
972
+ // only a declared local endpoint may hold one; it must hold a bound address,
973
+ // which rtnetlink verifies at apply, recovery and inspection.
974
+ value.local_platform_endpoints.sort();
975
+ require(
976
+ value
977
+ .local_platform_endpoints
978
+ .windows(2)
979
+ .all(|pair| pair[0] != pair[1]),
980
+ )?;
981
+ for id in &value.local_platform_endpoints {
982
+ let endpoint = value
983
+ .protection
984
+ .platform_endpoints
985
+ .iter()
986
+ .find(|endpoint| endpoint.id == *id)
987
+ .ok_or(Error::Invalid)?;
988
+ require(local_addresses.contains(&endpoint.address))?;
989
+ }
881
990
  for ip in &local_addresses {
882
991
  require(
883
992
  covers_address(&value.protection.prefixes, ip)?
884
- && value
885
- .protection
886
- .platform_endpoints
887
- .iter()
888
- .all(|endpoint| endpoint.address != *ip),
993
+ && value.protection.platform_endpoints.iter().all(|endpoint| {
994
+ endpoint.address != *ip || value.local_platform_endpoints.contains(&endpoint.id)
995
+ }),
889
996
  )?;
890
997
  }
891
998
  for handoff in &mut value.handoffs {
@@ -949,8 +1056,17 @@ fn normalize_host(value: &mut HostScope) -> Result<()> {
949
1056
  #[path = "egress_hostgrant_tests.rs"]
950
1057
  pub(crate) mod hostgrant_tests;
951
1058
  #[cfg(test)]
1059
+ #[path = "egress_localplatform_tests.rs"]
1060
+ pub(crate) mod localplatform_tests;
1061
+ #[cfg(test)]
1062
+ #[path = "egress_localport_tests.rs"]
1063
+ pub(crate) mod localport_tests;
1064
+ #[cfg(test)]
952
1065
  #[path = "egress_tests.rs"]
953
1066
  pub(crate) mod tests;
1067
+ #[cfg(test)]
1068
+ #[path = "egress_workloadgrant_tests.rs"]
1069
+ pub(crate) mod workloadgrant_tests;
954
1070
  impl Policy {
955
1071
  pub fn prepare(self) -> Result<Prepared> {
956
1072
  use sha2::{Digest, Sha256};
@@ -0,0 +1,88 @@
1
+ //! Workload grants: stateful one-way flows from one workload endpoint to one
2
+ //! exact port of another, both behind the same router. Like host grants they
3
+ //! compile to a constant number of rules around exact set lookups. The tuple key
4
+ //! carries both protocols and the live and originally tracked tuple, so a
5
+ //! translated flow never matches. A second set binds every grant address to the
6
+ //! exact link that holds it (index and name), and both the incoming and the
7
+ //! outgoing link are looked up on every packet. Only the grant's source opens;
8
+ //! the destination only answers.
9
+ use super::hostgrant::{lookup, meta_to, payload_to, tuple_key, tuple_lookup};
10
+ use super::*;
11
+ use std::collections::BTreeSet;
12
+
13
+ /// Every exact workload-to-workload flow of the scope.
14
+ pub(super) const TUPLE_SET: &str = "workload_grant";
15
+ /// Every grant address with the exact link that holds it.
16
+ pub(super) const LINK_SET: &str = "workload_link";
17
+
18
+ /// The link a packet crosses and the address on its side of that link: the
19
+ /// incoming link and the source, or the outgoing link and the destination.
20
+ fn link_lookup(incoming: bool) -> Vec<Attr> {
21
+ vec![
22
+ meta_to(if incoming { 4 } else { 5 }, 4),
23
+ meta_to(if incoming { 6 } else { 7 }, 5),
24
+ payload_to(1, if incoming { 12 } else { 16 }, 4, 9),
25
+ lookup(LINK_SET),
26
+ ]
27
+ }
28
+ fn link_key(link: &LocalLink, address: &str) -> Result<Vec<u8>> {
29
+ let mut key = link.interface_index.to_ne_bytes().to_vec();
30
+ let mut name = link.interface_name.as_bytes().to_vec();
31
+ name.resize(16, 0);
32
+ key.extend(name);
33
+ key.extend(super::address(address)?.to_be_bytes());
34
+ Ok(key)
35
+ }
36
+
37
+ pub(super) fn sets(program: &mut Program<'_>, scope: &RouterScope) -> Result<()> {
38
+ if scope.workload_grants.is_empty() {
39
+ return Ok(());
40
+ }
41
+ let mut keys = Vec::new();
42
+ let mut links = BTreeSet::new();
43
+ for grant in &scope.workload_grants {
44
+ keys.push(tuple_key(grant, None)?);
45
+ for address in [&grant.source_address, &grant.destination_address] {
46
+ links.insert(link_key(
47
+ super::router::endpoint_link(scope, address)?,
48
+ address,
49
+ )?);
50
+ }
51
+ }
52
+ program.set(TUPLE_SET, 3, 32)?;
53
+ program.elements(TUPLE_SET, keys)?;
54
+ program.set(LINK_SET, 4, 24)?;
55
+ program.elements(LINK_SET, links.into_iter().collect())
56
+ }
57
+
58
+ /// Four forward admissions whatever the number of grants: the ESTABLISHED
59
+ /// original direction, a NEW UDP opening, a NEW TCP opening restricted to SYN
60
+ /// with FIN/RST/ACK clear, and the ESTABLISHED reply, each in the default
61
+ /// conntrack zone. A reply is only ever ESTABLISHED, so the destination
62
+ /// workload can never open a flow toward the source through a grant.
63
+ pub(super) fn admissions(program: &mut Program<'_>, scope: &RouterScope) -> Result<()> {
64
+ if scope.workload_grants.is_empty() {
65
+ return Ok(());
66
+ }
67
+ let admission = |reply: bool, connection_state: u32, transport: Option<&str>| {
68
+ let mut result = ipv4();
69
+ result.extend(ct(17, None, 0_u16.to_ne_bytes().to_vec()));
70
+ result.extend(ct(1, None, vec![u8::from(reply)]));
71
+ result.extend(state(connection_state));
72
+ if let Some(transport) = transport {
73
+ result.extend(meta(16, vec![protocol(transport)]));
74
+ if transport == "tcp" {
75
+ result.extend(opening_tcp());
76
+ }
77
+ }
78
+ result.extend(link_lookup(true));
79
+ result.extend(link_lookup(false));
80
+ result.extend(tuple_lookup(TUPLE_SET, false, reply));
81
+ result
82
+ };
83
+ program.end("forward", admission(false, 2, None), 1)?;
84
+ for transport in ["udp", "tcp"] {
85
+ program.end("forward", admission(false, 8, Some(transport)), 1)?;
86
+ }
87
+ program.end("forward", admission(true, 2, None), 1)
88
+ }
@@ -15,15 +15,15 @@ pub(crate) fn granted(mut value: Value, grants: Value) -> Value {
15
15
  value["scope"]["hostGrants"] = grants;
16
16
  value
17
17
  }
18
- fn normalized(value: Value) -> crate::Result<Policy> {
18
+ pub(super) fn normalized(value: Value) -> crate::Result<Policy> {
19
19
  serde_json::from_value::<Policy>(value)
20
20
  .map_err(|_| crate::Error::Invalid)?
21
21
  .normalize()
22
22
  }
23
- fn prepared(value: Value) -> Prepared {
23
+ pub(super) fn prepared(value: Value) -> Prepared {
24
24
  normalized(value).unwrap().prepare().unwrap()
25
25
  }
26
- fn bytes(prepared: &Prepared) -> Vec<u8> {
26
+ pub(super) fn bytes(prepared: &Prepared) -> Vec<u8> {
27
27
  let mut result = Vec::new();
28
28
  for (kind, attributes) in prepared.program("snft_host_grant").unwrap() {
29
29
  result.extend(kind.to_be_bytes());
@@ -38,28 +38,36 @@ fn exact() -> Value {
38
38
 
39
39
  /// One packet with the conntrack facts a filter hook observes.
40
40
  #[derive(Clone, Copy)]
41
- struct Flow {
42
- input: Option<(u32, &'static str)>,
43
- output: Option<(u32, &'static str)>,
44
- protocol: u8,
45
- source: [u8; 4],
46
- destination: [u8; 4],
47
- source_port: u16,
48
- destination_port: u16,
49
- tcp_flags: u8,
50
- state: u32,
51
- reply: bool,
52
- zone: u16,
41
+ pub(super) struct Flow {
42
+ pub(super) input: Option<(u32, &'static str)>,
43
+ pub(super) output: Option<(u32, &'static str)>,
44
+ pub(super) protocol: u8,
45
+ pub(super) source: [u8; 4],
46
+ pub(super) destination: [u8; 4],
47
+ pub(super) source_port: u16,
48
+ pub(super) destination_port: u16,
49
+ pub(super) tcp_flags: u8,
50
+ pub(super) state: u32,
51
+ pub(super) reply: bool,
52
+ pub(super) zone: u16,
53
53
  /// Original-direction conntrack tuple: source, destination, ports.
54
- original: ([u8; 4], [u8; 4], u16, u16),
54
+ pub(super) original: ([u8; 4], [u8; 4], u16, u16),
55
+ /// The file system uid of the packet's user socket; None without one, when
56
+ /// `meta skuid` breaks the rule like the kernel.
57
+ pub(super) uid: Option<u32>,
55
58
  }
56
59
  const HOST: [u8; 4] = [10, 240, 0, 1];
57
60
  const WORK: [u8; 4] = [10, 241, 0, 2];
58
61
  const NEW: u32 = 8;
59
- const ESTABLISHED: u32 = 2;
62
+ pub(super) const ESTABLISHED: u32 = 2;
60
63
  const SYN: u8 = 0x02;
61
- const ACK: u8 = 0x10;
62
- fn opening(input: Option<(u32, &'static str)>, output: Option<(u32, &'static str)>) -> Flow {
64
+ pub(super) const ACK: u8 = 0x10;
65
+ /// The `reject with tcp reset` decision, distinct from a plain drop.
66
+ pub(super) const REJECT: i32 = i32::MIN;
67
+ pub(super) fn opening(
68
+ input: Option<(u32, &'static str)>,
69
+ output: Option<(u32, &'static str)>,
70
+ ) -> Flow {
63
71
  Flow {
64
72
  input,
65
73
  output,
@@ -73,6 +81,7 @@ fn opening(input: Option<(u32, &'static str)>, output: Option<(u32, &'static str
73
81
  reply: false,
74
82
  zone: 0,
75
83
  original: (HOST, WORK, 443, 8080),
84
+ uid: None,
76
85
  }
77
86
  }
78
87
  /// The workload's answer to `flow`, arriving on the reverse links.
@@ -181,17 +190,17 @@ fn foreign(flow: Flow) -> Vec<Flow> {
181
190
  /// kernel does: the terminal verdict, the chain that reached it (`policy` for the
182
191
  /// base chain's accept policy) and the conntrack zone a raw rule stored.
183
192
  #[derive(Debug, PartialEq, Eq)]
184
- struct Decision {
185
- verdict: i32,
186
- chain: String,
187
- zone: u16,
193
+ pub(super) struct Decision {
194
+ pub(super) verdict: i32,
195
+ pub(super) chain: String,
196
+ pub(super) zone: u16,
188
197
  }
189
198
  /// The compiled table: rules per chain and element keys per set.
190
199
  struct Table {
191
200
  chains: BTreeMap<String, Vec<Vec<Attr>>>,
192
201
  sets: BTreeMap<String, Vec<Vec<u8>>>,
193
202
  }
194
- fn chains(prepared: &Prepared) -> BTreeMap<String, Vec<Vec<Attr>>> {
203
+ pub(super) fn chains(prepared: &Prepared) -> BTreeMap<String, Vec<Vec<Attr>>> {
195
204
  table(prepared).chains
196
205
  }
197
206
  fn table(prepared: &Prepared) -> Table {
@@ -246,6 +255,7 @@ fn load(flow: &Flow, name: &str, fields: &[Attr]) -> Option<Vec<u8>> {
246
255
  Some(match name {
247
256
  "meta" => match wire::number(fields, 2).unwrap() {
248
257
  4 => interface(flow.input, false),
258
+ 10 => flow.uid?.to_ne_bytes().to_vec(),
249
259
  5 => interface(flow.output, false),
250
260
  6 => interface(flow.input, true),
251
261
  7 => interface(flow.output, true),
@@ -340,10 +350,14 @@ fn run(table: &Table, chain: &str, flow: &Flow, zone: &mut u16) -> Step {
340
350
  }
341
351
  }
342
352
  "cmp" => {
343
- assert_eq!(wire::number(&fields, 2).unwrap(), 0);
344
353
  let expected = data(&fields, 3);
345
354
  let at = offset(&fields, 1);
346
- matched = registers[at..at + expected.len()] == expected[..];
355
+ let equal = registers[at..at + expected.len()] == expected[..];
356
+ matched = match wire::number(&fields, 2).unwrap() {
357
+ 0 => equal,
358
+ 1 => !equal,
359
+ operation => panic!("cmp {operation}"),
360
+ };
347
361
  }
348
362
  "range" => {
349
363
  assert_eq!(wire::number(&fields, 2).unwrap(), 0);
@@ -381,6 +395,10 @@ fn run(table: &Table, chain: &str, flow: &Flow, zone: &mut u16) -> Step {
381
395
  registers[at..at + value.len()].copy_from_slice(&value);
382
396
  }
383
397
  "nat" => {}
398
+ "reject" => {
399
+ assert_eq!(fields, vec![Attr::u32(1, 1)]);
400
+ verdict = Some((REJECT, None));
401
+ }
384
402
  other => panic!("{other}"),
385
403
  }
386
404
  if !matched {
@@ -391,7 +409,7 @@ fn run(table: &Table, chain: &str, flow: &Flow, zone: &mut u16) -> Step {
391
409
  continue;
392
410
  }
393
411
  match verdict {
394
- Some((code @ (0 | 1), _)) => {
412
+ Some((code @ (0 | 1 | REJECT), _)) => {
395
413
  return Step::Final(Decision {
396
414
  verdict: code,
397
415
  chain: chain.into(),
@@ -412,7 +430,7 @@ fn run(table: &Table, chain: &str, flow: &Flow, zone: &mut u16) -> Step {
412
430
  Step::Next
413
431
  }
414
432
  /// A base chain that ends without a terminal verdict accepts by policy.
415
- fn decide(prepared: &Prepared, chain: &str, flow: &Flow) -> Decision {
433
+ pub(super) fn decide(prepared: &Prepared, chain: &str, flow: &Flow) -> Decision {
416
434
  let mut zone = 0;
417
435
  match run(&table(prepared), chain, flow, &mut zone) {
418
436
  Step::Final(decision) => decision,
@@ -793,7 +811,7 @@ fn host_grants_reject_ranges_wildcards_and_unbound_addresses() {
793
811
  }
794
812
 
795
813
  /// `count` distinct exact grants to the fixture workload, both protocols.
796
- fn many(count: usize) -> Value {
814
+ pub(super) fn many(count: usize) -> Value {
797
815
  json!((0..count)
798
816
  .map(|index| host_grant(
799
817
  if index % 2 == 0 { "tcp" } else { "udp" },
@@ -0,0 +1,204 @@
1
+ //! Host-local platform endpoints in host transit: leased flows from a handoff
2
+ //! into a platform endpoint served on a host address, and nothing else.
3
+ use super::hostgrant_tests::{
4
+ bytes, chains, decide, normalized, opening, prepared, Flow, ACK, ESTABLISHED,
5
+ };
6
+ use super::tests::host;
7
+ use serde_json::{json, Value};
8
+
9
+ const TRANSIT: [u8; 4] = [10, 240, 0, 2];
10
+ const UPLINK: [u8; 4] = [192, 0, 2, 2];
11
+ const HANDOFF: [u8; 4] = [10, 240, 0, 1];
12
+ const LINK: Option<(u32, &str)> = Some((4, "handoff"));
13
+
14
+ /// The host fixture with two platform endpoints on host addresses: a TCP hub
15
+ /// on the uplink address and a UDP store on the handoff address.
16
+ pub(crate) fn served(mut value: Value) -> Value {
17
+ value["scope"]["protection"]["platformEndpoints"]
18
+ .as_array_mut()
19
+ .unwrap()
20
+ .extend([
21
+ json!({"id":"hub","address":"192.0.2.2","protocol":"tcp","port":8443}),
22
+ json!({"id":"store","address":"10.240.0.1","protocol":"udp","port":7000}),
23
+ ]);
24
+ value
25
+ }
26
+ pub(crate) fn local(mut value: Value, ids: Value) -> Value {
27
+ value["scope"]["localPlatformEndpoints"] = ids;
28
+ value
29
+ }
30
+ fn exact() -> Value {
31
+ local(served(host()), json!(["store", "hub"]))
32
+ }
33
+ /// A leased opening from the transit source address arriving on the handoff.
34
+ fn arrival(protocol: u8, source_port: u16, destination: [u8; 4], port: u16) -> Flow {
35
+ Flow {
36
+ input: LINK,
37
+ output: None,
38
+ protocol,
39
+ source: TRANSIT,
40
+ destination,
41
+ source_port,
42
+ destination_port: port,
43
+ original: (TRANSIT, destination, source_port, port),
44
+ ..opening(LINK, None)
45
+ }
46
+ }
47
+ /// The host's answer to `flow`, leaving on the handoff.
48
+ fn answer(flow: Flow) -> Flow {
49
+ Flow {
50
+ input: None,
51
+ output: LINK,
52
+ source: flow.destination,
53
+ destination: flow.source,
54
+ source_port: flow.destination_port,
55
+ destination_port: flow.source_port,
56
+ tcp_flags: ACK,
57
+ state: ESTABLISHED,
58
+ reply: true,
59
+ ..flow
60
+ }
61
+ }
62
+
63
+ #[test]
64
+ fn local_platform_endpoints_absent_and_empty_keep_the_host_digest_and_compiled_bytes() {
65
+ let baseline = prepared(host());
66
+ let empty = prepared(local(host(), json!([])));
67
+ assert_eq!(empty, baseline);
68
+ assert_eq!(bytes(&empty), bytes(&baseline));
69
+ let with = prepared(exact());
70
+ assert!(serde_json::to_string(&with.policy)
71
+ .unwrap()
72
+ .contains("\"localPlatformEndpoints\":[\"hub\",\"store\"]"));
73
+ // Canonical order.
74
+ assert_eq!(
75
+ prepared(local(served(host()), json!(["hub", "store"]))),
76
+ with
77
+ );
78
+ // The endpoints and the chains exist only when declared.
79
+ let graph = chains(&with);
80
+ assert!(
81
+ graph.contains_key("local_platform_in")
82
+ && !chains(&baseline).contains_key("local_platform_in")
83
+ );
84
+ }
85
+
86
+ #[test]
87
+ fn local_platform_endpoint_on_a_host_address_is_refused_unless_declared() {
88
+ // The refusal stays for every undeclared endpoint on a host address.
89
+ assert!(normalized(served(host())).is_err());
90
+ assert!(normalized(local(served(host()), json!(["hub"]))).is_err());
91
+ assert!(normalized(local(served(host()), json!(["store"]))).is_err());
92
+ assert!(normalized(exact()).is_ok());
93
+ for invalid in [
94
+ // Unknown, duplicate, or an endpoint the host does not hold.
95
+ json!(["hub", "store", "missing"]),
96
+ json!(["hub", "store", "hub"]),
97
+ json!(["hub", "store", "dns"]),
98
+ json!(["hub", "store", "relay"]),
99
+ json!(["hub", "store", ""]),
100
+ json!(["hub", "store", 1]),
101
+ json!("hub"),
102
+ json!({"hub": true}),
103
+ ] {
104
+ assert!(
105
+ normalized(local(served(host()), invalid.clone())).is_err(),
106
+ "{invalid}"
107
+ );
108
+ }
109
+ }
110
+
111
+ #[test]
112
+ fn local_platform_endpoint_admits_only_leased_exact_untranslated_flows_and_their_replies() {
113
+ let baseline = prepared(host());
114
+ let with = prepared(exact());
115
+ let tcp = arrival(6, 10500, UPLINK, 8443);
116
+ let udp = arrival(17, 10500, HANDOFF, 7000);
117
+ let decided = |flow: &Flow| {
118
+ let chain = if flow.input.is_some() {
119
+ "input"
120
+ } else {
121
+ "output"
122
+ };
123
+ (
124
+ decide(&baseline, chain, flow).verdict,
125
+ decide(&with, chain, flow).verdict,
126
+ )
127
+ };
128
+ // Every admitted packet: the TCP opening, established TCP, the UDP opening,
129
+ // and both replies. The baseline denies each at the handoff.
130
+ for flow in [
131
+ tcp,
132
+ Flow {
133
+ state: ESTABLISHED,
134
+ tcp_flags: ACK,
135
+ ..tcp
136
+ },
137
+ udp,
138
+ answer(tcp),
139
+ answer(udp),
140
+ ] {
141
+ assert_eq!(decided(&flow), (0, 1));
142
+ }
143
+ // Everything else keeps the baseline decision: a source port outside the
144
+ // leased range of the protocol, another source address, another port or
145
+ // protocol, a flow translated in transit, another zone, a TCP opening
146
+ // without SYN, and a host-origin opening toward the transit address.
147
+ for flow in [
148
+ arrival(6, 20000, UPLINK, 8443),
149
+ arrival(6, 9999, UPLINK, 8443),
150
+ arrival(6, 10500, UPLINK, 8444),
151
+ arrival(17, 10500, UPLINK, 8443),
152
+ arrival(6, 10500, HANDOFF, 7000),
153
+ arrival(17, 10500, HANDOFF, 7001),
154
+ Flow {
155
+ source: [10, 240, 0, 3],
156
+ original: ([10, 240, 0, 3], UPLINK, 10500, 8443),
157
+ ..tcp
158
+ },
159
+ Flow {
160
+ original: (TRANSIT, HANDOFF, 10500, 9000),
161
+ ..tcp
162
+ },
163
+ Flow { zone: 7, ..tcp },
164
+ Flow {
165
+ tcp_flags: ACK,
166
+ ..tcp
167
+ },
168
+ Flow {
169
+ reply: false,
170
+ state: 8,
171
+ tcp_flags: 0x02,
172
+ original: (UPLINK, TRANSIT, 8443, 10500),
173
+ ..answer(tcp)
174
+ },
175
+ Flow {
176
+ state: 8,
177
+ ..answer(tcp)
178
+ },
179
+ ] {
180
+ let (before, after) = decided(&flow);
181
+ assert_eq!(after, before, "{:?}", flow.original);
182
+ }
183
+ // A translated arrival that the source range admits is still refused: the
184
+ // original tuple is part of every check.
185
+ assert_eq!(
186
+ decided(&Flow {
187
+ original: (TRANSIT, HANDOFF, 10500, 9000),
188
+ ..tcp
189
+ }),
190
+ (0, 0)
191
+ );
192
+ // Forwarded traffic and the uplink keep their exact previous rules. (The
193
+ // protected chains list every platform endpoint, served here or not.)
194
+ let graph = chains(&with);
195
+ let reference = chains(&baseline);
196
+ for name in ["forward", "post"] {
197
+ assert_eq!(graph[name], reference[name], "{name}");
198
+ }
199
+ // One jump per leased range per direction, ahead of the handoff denials.
200
+ assert_eq!(graph["input"].len(), reference["input"].len() + 2);
201
+ assert_eq!(graph["output"].len(), reference["output"].len() + 2);
202
+ assert_eq!(graph["local_platform_in"].len(), 4);
203
+ assert_eq!(graph["local_platform_back"].len(), 2);
204
+ }