@push.rocks/smartnftables 2.3.0 → 2.5.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.
Files changed (36) hide show
  1. package/changelog.md +19 -0
  2. package/dist_rust/smartnftables_linux_amd64_musl +0 -0
  3. package/dist_rust/smartnftables_linux_amd64_musl.tsrust-build.json +5 -5
  4. package/dist_rust/smartnftables_linux_arm64_musl +0 -0
  5. package/dist_rust/smartnftables_linux_arm64_musl.tsrust-build.json +5 -5
  6. package/dist_ts/00_commitinfo_data.js +1 -1
  7. package/dist_ts/classes.manageddockerforwarding.js +3 -3
  8. package/dist_ts/classes.managednftables.d.ts +5 -0
  9. package/dist_ts/classes.managednftables.js +13 -6
  10. package/dist_ts/managed.egress.types.d.ts +53 -1
  11. package/package.json +7 -7
  12. package/readme.md +153 -4
  13. package/rust/src/egress.compile.rs +49 -1
  14. package/rust/src/egress.host.rs +22 -0
  15. package/rust/src/egress.hostgrant.rs +187 -0
  16. package/rust/src/egress.poolguard.rs +7 -0
  17. package/rust/src/egress.router.rs +189 -3
  18. package/rust/src/egress.rs +244 -1
  19. package/rust/src/egress_hostgrant_tests.rs +948 -0
  20. package/rust/src/egress_tests.rs +414 -0
  21. package/rust/src/main.rs +6 -2
  22. package/rust/src/owner.rs +116 -4
  23. package/rust/src/owner_egress_tests.rs +1 -1
  24. package/rust/src/owner_egress_traffic_tests.rs +3 -3
  25. package/rust/src/owner_host_traffic_tests.rs +4 -4
  26. package/rust/src/owner_hostgrant_traffic_tests.rs +280 -0
  27. package/rust/src/owner_identity_tests.rs +125 -1
  28. package/rust/src/owner_poolguard_tests.rs +69 -0
  29. package/rust/src/owner_router_traffic_tests.rs +393 -0
  30. package/rust/src/owner_tests.rs +6 -0
  31. package/rust/src/policy.rs +1 -1
  32. package/rust/src/wire.rs +15 -1
  33. package/ts/00_commitinfo_data.ts +1 -1
  34. package/ts/classes.manageddockerforwarding.ts +2 -2
  35. package/ts/classes.managednftables.ts +13 -5
  36. package/ts/managed.egress.types.ts +55 -1
@@ -4,27 +4,74 @@ use crate::wire::{self, Attr};
4
4
 
5
5
  #[path = "egress.host.rs"]
6
6
  mod host;
7
+ #[path = "egress.hostgrant.rs"]
8
+ mod hostgrant;
7
9
  #[path = "egress.poolguard.rs"]
8
10
  mod poolguard;
9
11
  #[path = "egress.router.rs"]
10
12
  mod router;
11
13
 
14
+ /// Set elements of one target. Together with the 100,000-byte rule graph and the
15
+ /// handle-only deletion of a previous maximum graph this bounds one replacement
16
+ /// batch below `wire::BATCH_BYTES`.
17
+ pub(super) const ELEMENT_BYTES: usize = 100_000;
18
+
12
19
  pub(super) struct Program<'a> {
13
20
  table: &'a str,
14
21
  stamp: String,
15
22
  ordinal: usize,
16
23
  bytes: usize,
24
+ element_bytes: usize,
17
25
  operations: Vec<(u16, Vec<Attr>)>,
18
26
  }
19
27
  impl<'a> Program<'a> {
20
28
  fn push(&mut self, operation: u16, attributes: Vec<Attr>) -> Result<()> {
21
29
  self.bytes += wire::encode_attrs(&attributes).len() + 20;
22
30
  // Same atomic replacement reserve as v1: a previous bounded graph must
23
- // fit its handle-only deletions beside the complete target in 240 KiB.
31
+ // fit its handle-only deletions beside the complete target, and the
32
+ // target's set elements, in one `wire::BATCH_BYTES` batch.
24
33
  require(self.bytes <= 100_000 && self.operations.len() < 768)?;
25
34
  self.operations.push((operation, attributes));
26
35
  Ok(())
27
36
  }
37
+ /// One named exact-match set. CONSTANT: its elements cannot change once a
38
+ /// rule binds it, so replacement always creates a complete new set.
39
+ fn set(&mut self, name: &str, id: u32, key_length: usize) -> Result<()> {
40
+ self.push(
41
+ 9,
42
+ vec![
43
+ Attr::string(1, self.table),
44
+ Attr::string(2, name),
45
+ Attr::u32(3, 2),
46
+ Attr::u32(4, 0),
47
+ Attr::u32(5, key_length as u32),
48
+ Attr::u32(10, id),
49
+ ],
50
+ )
51
+ }
52
+ /// The elements of one set, 256 to a message so every nested attribute
53
+ /// stays far below netlink's 16-bit attribute length. Elements have their
54
+ /// own budget: a replacement deletes the previous set, and so its elements,
55
+ /// by one handle.
56
+ fn elements(&mut self, name: &str, keys: Vec<Vec<u8>>) -> Result<()> {
57
+ for chunk in keys.chunks(256) {
58
+ let elements = chunk
59
+ .iter()
60
+ .map(|key| {
61
+ Attr::nested(1, vec![Attr::nested(1, vec![Attr::bytes(1, key.clone())])])
62
+ })
63
+ .collect();
64
+ let attributes = vec![
65
+ Attr::string(1, self.table),
66
+ Attr::string(2, name),
67
+ Attr::nested(3, elements),
68
+ ];
69
+ self.element_bytes += wire::encode_attrs(&attributes).len() + 20;
70
+ require(self.element_bytes <= ELEMENT_BYTES && self.operations.len() < 768)?;
71
+ self.operations.push((12, attributes));
72
+ }
73
+ Ok(())
74
+ }
28
75
  fn chain(&mut self, name: &str, base: Option<(u32, i32, &str)>) -> Result<()> {
29
76
  let mut data = vec![Attr::string(1, self.table), Attr::string(3, name)];
30
77
  if let Some((hook, priority, kind)) = base {
@@ -298,6 +345,7 @@ pub(super) fn compile(prepared: &Prepared, table: &str) -> Result<Vec<(u16, Vec<
298
345
  stamp: format!("snft2:{}:{}", prepared.policy.revision, prepared.digest),
299
346
  ordinal: 0,
300
347
  bytes: 0,
348
+ element_bytes: 0,
301
349
  operations: Vec::new(),
302
350
  };
303
351
  match &prepared.policy.scope {
@@ -135,12 +135,34 @@ pub(super) fn compile(program: &mut Program<'_>, scope: &HostScope) -> Result<()
135
135
  for name in ["protected_out", "protected_back"] {
136
136
  program.chain(name, None)?;
137
137
  }
138
+ if !scope.host_grants.is_empty() {
139
+ // Validation admits only an address of exactly one handoff link.
140
+ hostgrant::tuple_set(program, &scope.host_grants, |grant| {
141
+ scope
142
+ .handoffs
143
+ .iter()
144
+ .find(|handoff| {
145
+ handoff
146
+ .link
147
+ .required_ipv4_addresses
148
+ .contains(&grant.source_address)
149
+ })
150
+ .map(|handoff| Some(&handoff.link))
151
+ .ok_or(crate::Error::Invalid)
152
+ })?;
153
+ }
138
154
  protection(program, scope, false)?;
139
155
  protection(program, scope, true)?;
140
156
  // Published admission precedes the capture barrier: the barrier denies every
141
157
  // protected original destination, which includes this uplink address, and an
142
158
  // exact inbound publication is the explicit authority for that exception.
143
159
  published(program, scope)?;
160
+ // Host-origin grants leave through the exact handoff that holds their source
161
+ // address, which is part of every set key, and precede the terminal output
162
+ // and input denials. Nothing else of the host's own traffic crosses a handoff.
163
+ if !scope.host_grants.is_empty() {
164
+ hostgrant::admissions(program, ["output", "input"], true, |_| vec![])?;
165
+ }
144
166
  for handoff in &scope.handoffs {
145
167
  program.jump("forward", link(&handoff.link, true), "protected_out")?;
146
168
  program.jump("forward", link(&handoff.link, false), "protected_back")?;
@@ -0,0 +1,187 @@
1
+ //! Host grants compile to a constant number of rules around exact-tuple set
2
+ //! lookups, so the rule graph does not grow with the number of grants. The key
3
+ //! carries every operand the rules would otherwise compare one by one: the
4
+ //! variable interface (index and name), the packet and conntrack protocols, the
5
+ //! live tuple and the originally tracked tuple. An element is one grant with its
6
+ //! live tuple equal to its original tuple, so translated flows never match.
7
+ use super::*;
8
+
9
+ /// Every exact host-origin flow of the scope, keyed on its full tuple.
10
+ pub(super) const TUPLE_SET: &str = "host_grant";
11
+ /// Router raw ingress runs before conntrack: exact arriving tuples only.
12
+ pub(super) const ARRIVAL_SET: &str = "host_grant_arrival";
13
+
14
+ /// Register numbers as Linux dumps them: 16-byte aligned registers use the
15
+ /// legacy NFT_REG_1..4 names, every other 4-byte register NFT_REG32_xx.
16
+ fn register(index: u32) -> u32 {
17
+ if index % 4 == 0 {
18
+ index / 4
19
+ } else {
20
+ index - 4 + 8
21
+ }
22
+ }
23
+ fn store(name: &str, mut data: Vec<Attr>, index: u32) -> Attr {
24
+ data.insert(0, Attr::u32(1, register(index)));
25
+ expr(name, data)
26
+ }
27
+ fn meta_to(key: u32, index: u32) -> Attr {
28
+ store("meta", vec![Attr::u32(2, key)], index)
29
+ }
30
+ fn payload_to(base: u32, offset: u32, length: u32, index: u32) -> Attr {
31
+ store(
32
+ "payload",
33
+ vec![
34
+ Attr::u32(2, base),
35
+ Attr::u32(3, offset),
36
+ Attr::u32(4, length),
37
+ ],
38
+ index,
39
+ )
40
+ }
41
+ fn ct_to(key: u32, original: bool, index: u32) -> Attr {
42
+ let mut data = vec![Attr::u32(2, key)];
43
+ if original {
44
+ data.push(Attr::bytes(3, vec![0]));
45
+ }
46
+ store("ct", data, index)
47
+ }
48
+ fn lookup(set: &str) -> Attr {
49
+ expr(
50
+ "lookup",
51
+ vec![
52
+ Attr::string(1, set),
53
+ Attr::u32(2, register(4)),
54
+ Attr::u32(5, 0),
55
+ ],
56
+ )
57
+ }
58
+ fn word(bytes: &[u8]) -> Vec<u8> {
59
+ let mut result = bytes.to_vec();
60
+ result.resize(4, 0);
61
+ result
62
+ }
63
+
64
+ /// Key loads for one direction, then the lookup. `linked` adds the variable
65
+ /// interface: the outgoing interface of the original direction, the incoming
66
+ /// interface of the reply.
67
+ fn tuple_lookup(linked: bool, reply: bool) -> Vec<Attr> {
68
+ let mut result = Vec::new();
69
+ let mut index = 4;
70
+ if linked {
71
+ result.push(meta_to(if reply { 4 } else { 5 }, index));
72
+ result.push(meta_to(if reply { 6 } else { 7 }, index + 1));
73
+ index += 5;
74
+ }
75
+ result.push(meta_to(16, index));
76
+ result.push(ct_to(10, false, index + 1));
77
+ // The grant's source and destination are the packet's own addresses in the
78
+ // original direction and swapped on the reply; the port is the workload's.
79
+ result.push(payload_to(1, if reply { 16 } else { 12 }, 4, index + 2));
80
+ result.push(payload_to(1, if reply { 12 } else { 16 }, 4, index + 3));
81
+ result.push(payload_to(2, if reply { 0 } else { 2 }, 2, index + 4));
82
+ result.push(ct_to(19, true, index + 5));
83
+ result.push(ct_to(20, true, index + 6));
84
+ result.push(ct_to(12, true, index + 7));
85
+ result.push(lookup(TUPLE_SET));
86
+ result
87
+ }
88
+ fn tuple_key(grant: &HostGrant, link: Option<&LocalLink>) -> Result<Vec<u8>> {
89
+ let protocol = word(&[protocol(&grant.protocol)]);
90
+ let source = address(&grant.source_address)?.to_be_bytes();
91
+ let destination = address(&grant.destination_address)?.to_be_bytes();
92
+ let port = word(&grant.destination_port.to_be_bytes());
93
+ let mut key = Vec::new();
94
+ if let Some(link) = link {
95
+ key.extend(link.interface_index.to_ne_bytes());
96
+ let mut name = link.interface_name.as_bytes().to_vec();
97
+ name.resize(16, 0);
98
+ key.extend(name);
99
+ }
100
+ for field in [
101
+ &protocol[..],
102
+ &protocol,
103
+ &source,
104
+ &destination,
105
+ &port,
106
+ &source,
107
+ &destination,
108
+ &port,
109
+ ] {
110
+ key.extend(field);
111
+ }
112
+ Ok(key)
113
+ }
114
+
115
+ /// The scope's exact tuple set. `link` returns the variable interface of a
116
+ /// grant, or None for a link-independent scope.
117
+ pub(super) fn tuple_set<'s>(
118
+ program: &mut Program<'_>,
119
+ grants: &'s [HostGrant],
120
+ link: impl Fn(&HostGrant) -> Result<Option<&'s LocalLink>>,
121
+ ) -> Result<()> {
122
+ let mut keys = Vec::new();
123
+ for grant in grants {
124
+ keys.push(tuple_key(grant, link(grant)?)?);
125
+ }
126
+ let length = keys.first().map(Vec::len).ok_or(crate::Error::Invalid)?;
127
+ program.set(TUPLE_SET, 1, length)?;
128
+ program.elements(TUPLE_SET, keys)
129
+ }
130
+ pub(super) fn arrival_set(program: &mut Program<'_>, grants: &[HostGrant]) -> Result<()> {
131
+ let mut keys = Vec::new();
132
+ for grant in grants {
133
+ let mut key = word(&[protocol(&grant.protocol)]);
134
+ key.extend(address(&grant.source_address)?.to_be_bytes());
135
+ key.extend(address(&grant.destination_address)?.to_be_bytes());
136
+ key.extend(word(&grant.destination_port.to_be_bytes()));
137
+ keys.push(key);
138
+ }
139
+ program.set(ARRIVAL_SET, 2, 16)?;
140
+ program.elements(ARRIVAL_SET, keys)
141
+ }
142
+ /// Exact arriving tuple, before conntrack: protocol, addresses and port.
143
+ pub(super) fn arrival_lookup() -> Vec<Attr> {
144
+ vec![
145
+ meta_to(16, 4),
146
+ payload_to(1, 12, 4, 5),
147
+ payload_to(1, 16, 4, 6),
148
+ payload_to(2, 2, 2, 7),
149
+ lookup(ARRIVAL_SET),
150
+ ]
151
+ }
152
+
153
+ /// Four admissions whatever the number of grants: the ESTABLISHED original
154
+ /// direction, a NEW UDP opening, a NEW TCP opening restricted to SYN with
155
+ /// FIN/RST/ACK clear, and the ESTABLISHED reply. Each checks the default
156
+ /// conntrack zone and the direction before one exact-tuple lookup. `fixed`
157
+ /// adds the scope's fixed interface for each direction.
158
+ pub(super) fn admissions(
159
+ program: &mut Program<'_>,
160
+ chains: [&str; 2],
161
+ linked: bool,
162
+ fixed: impl Fn(bool) -> Vec<Attr>,
163
+ ) -> Result<()> {
164
+ let envelope = |reply: bool, connection_state: u32| {
165
+ let mut result = ipv4();
166
+ result.extend(fixed(reply));
167
+ result.extend(ct(17, None, 0_u16.to_ne_bytes().to_vec()));
168
+ result.extend(ct(1, None, vec![u8::from(reply)]));
169
+ result.extend(state(connection_state));
170
+ result
171
+ };
172
+ let mut established = envelope(false, 2);
173
+ established.extend(tuple_lookup(linked, false));
174
+ program.end(chains[0], established, 1)?;
175
+ for transport in ["udp", "tcp"] {
176
+ let mut opening = envelope(false, 8);
177
+ opening.extend(meta(16, vec![protocol(transport)]));
178
+ if transport == "tcp" {
179
+ opening.extend(opening_tcp());
180
+ }
181
+ opening.extend(tuple_lookup(linked, false));
182
+ program.end(chains[0], opening, 1)?;
183
+ }
184
+ let mut reply = envelope(true, 2);
185
+ reply.extend(tuple_lookup(linked, true));
186
+ program.end(chains[1], reply, 1)
187
+ }
@@ -7,6 +7,13 @@ pub(super) fn compile(program: &mut Program<'_>, scope: &AllocationPoolGuardScop
7
7
  for name in ["pool_guard", "pool_reply"] {
8
8
  program.chain(name, None)?;
9
9
  }
10
+ // The only exceptions: an exact host-origin flow into a pool and its reply,
11
+ // ahead of the guard. Everything else, including a translated flow that merely
12
+ // arrives at a granted tuple, still meets the unconditional guard below.
13
+ if !scope.host_grants.is_empty() {
14
+ hostgrant::tuple_set(program, &scope.host_grants, |_| Ok(None))?;
15
+ hostgrant::admissions(program, ["output", "input"], false, |_| vec![])?;
16
+ }
10
17
  for name in ["input", "forward", "output"] {
11
18
  program.jump(name, ipv4(), "pool_guard")?;
12
19
  }
@@ -47,6 +47,161 @@ fn raw_return(scope: &RouterScope, grant: &Grant, generation: &Generation) -> Re
47
47
  ));
48
48
  Ok(result)
49
49
  }
50
+ /// The exact veth link of the workload endpoint that holds an endpoint address.
51
+ /// Validation admits only a current endpoint address, and endpoint prefixes are
52
+ /// pairwise disjoint, so one publication or host grant never spans two links.
53
+ fn endpoint_link<'a>(scope: &'a RouterScope, address: &str) -> Result<&'a LocalLink> {
54
+ let target = format!("{address}/32");
55
+ let endpoint = scope
56
+ .endpoints
57
+ .iter()
58
+ .find(|endpoint| {
59
+ endpoint
60
+ .source_prefixes
61
+ .iter()
62
+ .any(|source| super::super::covers(source, &target) == Ok(true))
63
+ })
64
+ .ok_or(crate::Error::Invalid)?;
65
+ scope
66
+ .links
67
+ .iter()
68
+ .find(|link| link.interface_index == endpoint.interface_index)
69
+ .ok_or(crate::Error::Invalid)
70
+ }
71
+ fn published_link<'a>(scope: &'a RouterScope, port: &RouterPublishedPort) -> Result<&'a LocalLink> {
72
+ endpoint_link(scope, &port.endpoint_address)
73
+ }
74
+ /// Inbound publication envelope. Both directions check the exact links, the
75
+ /// translated current tuple and the original conntrack destination, so foreign
76
+ /// NAT cannot borrow this admission for another flow.
77
+ fn published_envelope(
78
+ scope: &RouterScope,
79
+ endpoint: &LocalLink,
80
+ port: &RouterPublishedPort,
81
+ reply: bool,
82
+ ) -> Result<Vec<Attr>> {
83
+ let mut expressions = ipv4();
84
+ expressions.extend(link(&scope.handoff, !reply));
85
+ expressions.extend(link(endpoint, reply));
86
+ expressions.extend(meta(16, vec![protocol(&port.protocol)]));
87
+ expressions.extend(ct(10, None, vec![protocol(&port.protocol)]));
88
+ expressions.extend(ct(17, None, 0_u16.to_ne_bytes().to_vec()));
89
+ expressions.extend(ct(1, None, vec![u8::from(reply)]));
90
+ let target = format!("{}/32", port.endpoint_address);
91
+ expressions.extend(current_address(&target, reply)?);
92
+ expressions.extend(current_port(port.endpoint_port, reply));
93
+ let published = format!("{}/32", port.transit_source_address);
94
+ expressions.extend(original_address(&published, false)?);
95
+ expressions.extend(original_port(port.transit_port, false));
96
+ Ok(expressions)
97
+ }
98
+ /// Handoff ingress classification for an exact current publication. Raw runs
99
+ /// before conntrack, so it matches the arriving tuple only and never sets a
100
+ /// generation zone: a publication is an ordinary default-zone flow.
101
+ fn published_return(program: &mut Program<'_>, scope: &RouterScope) -> Result<()> {
102
+ for port in &scope.published_ports {
103
+ let mut expressions = ipv4();
104
+ expressions.extend(link(&scope.handoff, true));
105
+ expressions.extend(meta(16, vec![protocol(&port.protocol)]));
106
+ expressions.extend(current_address(
107
+ &format!("{}/32", port.transit_source_address),
108
+ false,
109
+ )?);
110
+ expressions.extend(current_port(port.transit_port, false));
111
+ program.end("raw_return", expressions, 1)?;
112
+ }
113
+ Ok(())
114
+ }
115
+ /// Endpoint ingress classification for an exact current publication. The workload
116
+ /// side of a publication is an ordinary default-zone flow too, so it is matched on
117
+ /// its exact link, address and dedicated published port before the leased
118
+ /// classifiers below can zone it: a client whose source port equals a leased
119
+ /// grant's destination port would otherwise move the workload's published packets
120
+ /// into that generation, where they are not its flow. This only classifies; both
121
+ /// the anti-spoof guards and the publication's admissions still decide admission.
122
+ fn published_endpoint(program: &mut Program<'_>, scope: &RouterScope) -> Result<()> {
123
+ for port in &scope.published_ports {
124
+ let mut expressions = ipv4();
125
+ expressions.extend(link(published_link(scope, port)?, true));
126
+ expressions.extend(meta(16, vec![protocol(&port.protocol)]));
127
+ expressions.extend(current_address(
128
+ &format!("{}/32", port.endpoint_address),
129
+ true,
130
+ )?);
131
+ expressions.extend(current_port(port.endpoint_port, true));
132
+ program.end("raw_in", expressions, 1)?;
133
+ }
134
+ Ok(())
135
+ }
136
+ /// Handoff ingress classification for an exact host grant. Like a publication it
137
+ /// is the explicit authority for its tuple, so it leads the protected-source
138
+ /// denial that otherwise drops every packet from the host's transit address, and
139
+ /// it never sets a generation zone: a host grant is an ordinary default-zone flow.
140
+ /// The workload's replies need no classifier of their own: their destination is
141
+ /// protected, so they return to the default zone ahead of every leased classifier.
142
+ fn host_grant_return(program: &mut Program<'_>, scope: &RouterScope) -> Result<()> {
143
+ if !scope.host_grants.is_empty() {
144
+ let mut expressions = ipv4();
145
+ expressions.extend(link(&scope.handoff, true));
146
+ expressions.extend(hostgrant::arrival_lookup());
147
+ program.end("raw_return", expressions, 1)?;
148
+ }
149
+ Ok(())
150
+ }
151
+ /// The workload link of each grant is part of its key; the handoff is fixed.
152
+ fn host_grant_admissions(program: &mut Program<'_>, scope: &RouterScope) -> Result<()> {
153
+ if !scope.host_grants.is_empty() {
154
+ hostgrant::admissions(program, ["forward", "forward"], true, |reply| {
155
+ link(&scope.handoff, !reply)
156
+ })?;
157
+ }
158
+ Ok(())
159
+ }
160
+ fn host_grant_sets(program: &mut Program<'_>, scope: &RouterScope) -> Result<()> {
161
+ if !scope.host_grants.is_empty() {
162
+ hostgrant::tuple_set(program, &scope.host_grants, |grant| {
163
+ endpoint_link(scope, &grant.destination_address).map(Some)
164
+ })?;
165
+ hostgrant::arrival_set(program, &scope.host_grants)?;
166
+ }
167
+ Ok(())
168
+ }
169
+ fn published_translation(program: &mut Program<'_>, scope: &RouterScope) -> Result<()> {
170
+ for port in &scope.published_ports {
171
+ let mut expressions = ipv4();
172
+ expressions.extend(link(&scope.handoff, true));
173
+ expressions.extend(meta(16, vec![protocol(&port.protocol)]));
174
+ expressions.extend(ct(10, None, vec![protocol(&port.protocol)]));
175
+ expressions.extend(ct(17, None, 0_u16.to_ne_bytes().to_vec()));
176
+ expressions.extend(ct(1, None, vec![0]));
177
+ expressions.extend(current_address(
178
+ &format!("{}/32", port.transit_source_address),
179
+ false,
180
+ )?);
181
+ expressions.extend(current_port(port.transit_port, false));
182
+ expressions.extend(dnat(&port.endpoint_address, port.endpoint_port)?);
183
+ program.rule("pre", expressions)?;
184
+ }
185
+ Ok(())
186
+ }
187
+ fn published_admissions(program: &mut Program<'_>, scope: &RouterScope) -> Result<()> {
188
+ for port in &scope.published_ports {
189
+ let endpoint = published_link(scope, port)?;
190
+ let original = published_envelope(scope, endpoint, port, false)?;
191
+ for connection_state in [2, 8] {
192
+ let mut expressions = original.clone();
193
+ expressions.extend(state(connection_state));
194
+ if connection_state == 8 && port.protocol == "tcp" {
195
+ expressions.extend(opening_tcp());
196
+ }
197
+ program.end("forward", expressions, 1)?;
198
+ }
199
+ let mut reply = published_envelope(scope, endpoint, port, true)?;
200
+ reply.extend(state(2));
201
+ program.end("forward", reply, 1)?;
202
+ }
203
+ Ok(())
204
+ }
50
205
  fn protected(
51
206
  program: &mut Program<'_>,
52
207
  chain: &str,
@@ -67,10 +222,20 @@ fn protected(
67
222
  program.end(chain, vec![], -5)
68
223
  }
69
224
  fn raw(program: &mut Program<'_>, scope: &RouterScope) -> Result<()> {
70
- // Handoff ingress must classify only exact leased return envelopes. Unknown
71
- // ingress never reaches private/default-zone rules, even after a rename.
225
+ // Handoff ingress must classify only exact leased return envelopes and exact
226
+ // inbound publications. Unknown ingress never reaches private/default-zone
227
+ // rules, even after a rename.
72
228
  program.jump("raw_in", link(&scope.handoff, true), "raw_return")?;
73
229
  program.deny_link("raw_in", &scope.handoff, true)?;
230
+ // A publication is the explicit authority for its exact tuple, so it is
231
+ // classified ahead of the protected-source denial below, exactly as a
232
+ // platform return envelope is. The host publishes into this address, so its
233
+ // clients are not confined to unprotected space.
234
+ published_return(program, scope)?;
235
+ host_grant_return(program, scope)?;
236
+ // Both sides of a publication lead the leased classifiers of every generation,
237
+ // so neither direction depends on the client's choice of source port.
238
+ published_endpoint(program, scope)?;
74
239
  for platform in [true, false] {
75
240
  if !platform {
76
241
  for prefix in &scope.protection.prefixes {
@@ -190,9 +355,16 @@ pub(super) fn compile(program: &mut Program<'_>, scope: &RouterScope, revision:
190
355
  ] {
191
356
  program.chain(name, Some((hook, priority, kind)))?;
192
357
  }
358
+ if !scope.published_ports.is_empty() {
359
+ // Published destination NAT lives in this table's own prerouting chain, so
360
+ // it is created, replaced and deleted with the exact generation. An empty
361
+ // publication list keeps the previous chain set and compiled bytes.
362
+ program.chain("pre", Some((0, -100, "nat")))?;
363
+ }
193
364
  for name in ["raw_return", "protected_out", "protected_back"] {
194
365
  program.chain(name, None)?;
195
366
  }
367
+ host_grant_sets(program, scope)?;
196
368
  let private = policy::Policy {
197
369
  schema_version: 1,
198
370
  revision,
@@ -210,9 +382,23 @@ pub(super) fn compile(program: &mut Program<'_>, scope: &RouterScope, revision:
210
382
  raw(program, scope)?;
211
383
  protected(program, "protected_out", scope, false)?;
212
384
  protected(program, "protected_back", scope, true)?;
385
+ published_translation(program, scope)?;
213
386
  for chain in ["input", "forward", "output"] {
214
387
  let parts = private.filter_rules(chain)?;
215
- for expressions in parts.guards.into_iter().chain(parts.grants) {
388
+ for expressions in parts.guards {
389
+ program.rule(chain, expressions)?;
390
+ }
391
+ // Published admission keeps the private anti-spoof guards ahead of it and
392
+ // precedes the capture barrier: the barrier denies every protected original
393
+ // destination, which includes this transit address, and an exact inbound
394
+ // publication is the explicit authority for that exception.
395
+ // Host grants take the same position for the same reason: the barrier
396
+ // denies the host's protected transit address in both directions.
397
+ if chain == "forward" {
398
+ published_admissions(program, scope)?;
399
+ host_grant_admissions(program, scope)?;
400
+ }
401
+ for expressions in parts.grants {
216
402
  program.rule(chain, expressions)?;
217
403
  }
218
404
  grants(program, scope, chain, true)?;