@push.rocks/smartnftables 4.1.0 → 4.2.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.
- package/changelog.md +9 -0
- package/dist_rust/smartnftables_linux_amd64_musl +0 -0
- package/dist_rust/smartnftables_linux_amd64_musl.tsrust-build.json +4 -4
- package/dist_rust/smartnftables_linux_arm64_musl +0 -0
- package/dist_rust/smartnftables_linux_arm64_musl.tsrust-build.json +4 -4
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.manageddockerforwarding.d.ts +2 -1
- package/dist_ts/classes.manageddockerforwarding.js +3 -2
- package/dist_ts/forwardhooks.d.ts +8 -0
- package/dist_ts/forwardhooks.js +42 -0
- package/dist_ts/index.d.ts +2 -0
- package/dist_ts/index.js +2 -1
- package/dist_ts/managed.docker.types.d.ts +2 -1
- package/dist_ts/managed.forwardhooks.types.d.ts +35 -0
- package/dist_ts/managed.forwardhooks.types.js +2 -0
- package/package.json +1 -1
- package/readme.md +69 -16
- package/rust/src/docker.frontend.rs +2 -2
- package/rust/src/docker.graph.rs +36 -11
- package/rust/src/docker.policy.rs +90 -31
- package/rust/src/docker.rs +37 -13
- package/rust/src/docker_tests.rs +161 -2
- package/rust/src/egress.host.rs +44 -45
- package/rust/src/egress.rs +56 -0
- package/rust/src/forwardhooks.rs +176 -0
- package/rust/src/forwardhooks_tests.rs +136 -0
- package/rust/src/main.rs +12 -8
- package/rust/src/wire.links.rs +28 -6
- package/rust/src/wire.rs +22 -2
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.manageddockerforwarding.ts +2 -1
- package/ts/forwardhooks.ts +39 -0
- package/ts/index.ts +2 -0
- package/ts/managed.docker.types.ts +2 -1
- package/ts/managed.forwardhooks.types.ts +26 -0
|
@@ -99,46 +99,105 @@ pub struct Rule {
|
|
|
99
99
|
fn port_range(first: u16, last: u16) -> String {
|
|
100
100
|
if first == last { first.to_string() } else { format!("{first}:{last}") }
|
|
101
101
|
}
|
|
102
|
+
/// One side of a packet or of its originally tracked tuple: an exact address
|
|
103
|
+
/// and an inclusive port span.
|
|
104
|
+
#[derive(Clone, Copy)]
|
|
105
|
+
struct Tuple<'a> {
|
|
106
|
+
address: &'a str,
|
|
107
|
+
first: u16,
|
|
108
|
+
last: u16,
|
|
109
|
+
}
|
|
110
|
+
/// One flow class of the barrier's forward chain, in its original direction:
|
|
111
|
+
/// it enters on `input`, leaves on `output`, carries `live` as its source
|
|
112
|
+
/// (`source`) or destination, and conntrack tracked it with `tracked` as its
|
|
113
|
+
/// original source (`source`) or destination. A leased or symmetric flow is
|
|
114
|
+
/// keyed on its own source, before outer source NAT; an inbound publication on
|
|
115
|
+
/// its translated inside destination and its published outside destination.
|
|
116
|
+
struct Flow<'a> {
|
|
117
|
+
input: &'a str,
|
|
118
|
+
output: &'a str,
|
|
119
|
+
protocol: &'a str,
|
|
120
|
+
source: bool,
|
|
121
|
+
live: Tuple<'a>,
|
|
122
|
+
tracked: Tuple<'a>,
|
|
123
|
+
}
|
|
124
|
+
impl Flow<'_> {
|
|
125
|
+
/// The ESTABLISHED original direction, a NEW opening (TCP only with SYN and
|
|
126
|
+
/// FIN/RST/ACK clear) and the ESTABLISHED reply, as the barrier admits them.
|
|
127
|
+
fn rules(&self, mut push: impl FnMut(Vec<String>) -> Result<()>) -> Result<()> {
|
|
128
|
+
for state in ["NEW", "ESTABLISHED", "REPLY"] {
|
|
129
|
+
let reply = state == "REPLY";
|
|
130
|
+
// The reply swaps the interfaces and the packet's tuple sides; the
|
|
131
|
+
// originally tracked tuple stays the same.
|
|
132
|
+
let source = self.source != reply;
|
|
133
|
+
let mut args: Vec<String> = vec![
|
|
134
|
+
"-i".into(), if reply { self.output } else { self.input }.into(),
|
|
135
|
+
"-o".into(), if reply { self.input } else { self.output }.into(),
|
|
136
|
+
if source { "-s" } else { "-d" }.into(), format!("{}/32", self.live.address),
|
|
137
|
+
"-p".into(), self.protocol.into(), "-m".into(), self.protocol.into(),
|
|
138
|
+
if source { "--sport" } else { "--dport" }.into(), port_range(self.live.first, self.live.last),
|
|
139
|
+
];
|
|
140
|
+
if state == "NEW" && self.protocol == "tcp" {
|
|
141
|
+
args.extend(["--tcp-flags","FIN,SYN,RST,ACK","SYN"].map(String::from));
|
|
142
|
+
}
|
|
143
|
+
let (address, port) = if self.source { ("--ctorigsrc", "--ctorigsrcport") } else { ("--ctorigdst", "--ctorigdstport") };
|
|
144
|
+
args.extend(["-m","conntrack","--ctstate",if reply {"ESTABLISHED"} else {state},"--ctproto",if self.protocol == "tcp" {"6"} else {"17"},address].map(String::from));
|
|
145
|
+
// Canonical xtables host form: explicit /32 has different unused
|
|
146
|
+
// conntrack mask bytes and does not survive a save/restore round trip.
|
|
147
|
+
args.push(self.tracked.address.into());
|
|
148
|
+
args.push(port.into());args.push(port_range(self.tracked.first,self.tracked.last));
|
|
149
|
+
args.extend(["--ctdir",if reply {"REPLY"} else {"ORIGINAL"}].map(String::from));
|
|
150
|
+
push(args)?;
|
|
151
|
+
}
|
|
152
|
+
Ok(())
|
|
153
|
+
}
|
|
154
|
+
}
|
|
102
155
|
impl Prepared {
|
|
103
156
|
pub fn validate(&self) -> Result<()> {
|
|
104
157
|
if self.policy.clone().prepare(self.backend.clone())? != *self { return Err(Error::Invalid); }
|
|
105
158
|
Ok(())
|
|
106
159
|
}
|
|
160
|
+
/// Every forwarded flow the barrier admits, from the scope's one forwarding
|
|
161
|
+
/// enumeration: leased ranges first, then each publication's inbound flows
|
|
162
|
+
/// and, when symmetric, the flows it opens from its target ports.
|
|
163
|
+
fn flows(&self) -> Result<Vec<Flow<'_>>> {
|
|
164
|
+
let scope = self.policy.scope()?;
|
|
165
|
+
let forwarding = scope.forwarding()?;
|
|
166
|
+
let uplink = scope.uplink.interface_name.as_str();
|
|
167
|
+
let mut flows = Vec::new();
|
|
168
|
+
for leased in &forwarding.leased {
|
|
169
|
+
let tuple = Tuple { address:&leased.allocation.transit_source_address, first:leased.range.first, last:leased.range.last };
|
|
170
|
+
flows.push(Flow { input:&leased.handoff.link.interface_name, output:uplink, protocol:&leased.range.protocol,
|
|
171
|
+
source:true, live:tuple, tracked:tuple });
|
|
172
|
+
}
|
|
173
|
+
for published in &forwarding.published {
|
|
174
|
+
let port = published.port;
|
|
175
|
+
let inside = Tuple { address:&port.target_address, first:port.target_port, last:port.target_last() };
|
|
176
|
+
let outside = Tuple { address:&port.host_ip, first:port.host_port, last:port.host_last() };
|
|
177
|
+
let link = published.link.interface_name.as_str();
|
|
178
|
+
flows.push(Flow { input:uplink, output:link, protocol:&port.protocol, source:false, live:inside, tracked:outside });
|
|
179
|
+
if port.symmetric {
|
|
180
|
+
flows.push(Flow { input:link, output:uplink, protocol:&port.protocol, source:true, live:inside, tracked:inside });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
Ok(flows)
|
|
184
|
+
}
|
|
107
185
|
pub fn rules(&self, options: &Options) -> Result<Vec<Rule>> {
|
|
108
186
|
options.validate()?;
|
|
109
|
-
let
|
|
187
|
+
let digest = self.digest.strip_prefix("sha256:").ok_or(Error::Invalid)?;
|
|
110
188
|
let mut result = Vec::new();
|
|
111
189
|
let mut bytes = 0;
|
|
112
|
-
for
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
];
|
|
124
|
-
if state == "NEW" && range.protocol == "tcp" {
|
|
125
|
-
args.extend(["--tcp-flags","FIN,SYN,RST,ACK","SYN"].map(String::from));
|
|
126
|
-
}
|
|
127
|
-
args.extend(["-m","conntrack","--ctstate",if reply {"ESTABLISHED"} else {state},"--ctproto",if range.protocol == "tcp" {"6"} else {"17"},"--ctorigsrc"].map(String::from));
|
|
128
|
-
// Canonical xtables host form: explicit /32 has different unused
|
|
129
|
-
// conntrack mask bytes and does not survive a save/restore round trip.
|
|
130
|
-
args.push(allocation.transit_source_address.clone());
|
|
131
|
-
args.push("--ctorigsrcport".into());args.push(port_range(range.first,range.last));
|
|
132
|
-
args.extend(["--ctdir",if reply {"REPLY"} else {"ORIGINAL"},"-m","comment","--comment"].map(String::from));
|
|
133
|
-
let comment = format!("snftd1:{}:{}:{}:{}",options.owner_id,options.instance_id,self.digest.strip_prefix("sha256:").ok_or(Error::Invalid)?,result.len());
|
|
134
|
-
args.push(comment.clone());args.extend(["-j","ACCEPT"].map(String::from));
|
|
135
|
-
bytes += args.iter().map(|arg|arg.len()+1).sum::<usize>()+32;
|
|
136
|
-
crate::capacity("restoreRules", 192, result.len() + 1)?;
|
|
137
|
-
crate::capacity("restoreBytes", 100_000, bytes)?;
|
|
138
|
-
result.push(Rule {comment,args});
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
190
|
+
for flow in self.flows()? {
|
|
191
|
+
flow.rules(|mut args| {
|
|
192
|
+
let comment = format!("snftd1:{}:{}:{}:{}",options.owner_id,options.instance_id,digest,result.len());
|
|
193
|
+
args.extend(["-m","comment","--comment"].map(String::from));
|
|
194
|
+
args.push(comment.clone());args.extend(["-j","ACCEPT"].map(String::from));
|
|
195
|
+
bytes += args.iter().map(|arg|arg.len()+1).sum::<usize>()+32;
|
|
196
|
+
crate::capacity("restoreRules", 192, result.len() + 1)?;
|
|
197
|
+
crate::capacity("restoreBytes", 100_000, bytes)?;
|
|
198
|
+
result.push(Rule {comment,args});
|
|
199
|
+
Ok(())
|
|
200
|
+
})?;
|
|
142
201
|
}
|
|
143
202
|
Ok(result)
|
|
144
203
|
}
|
package/rust/src/docker.rs
CHANGED
|
@@ -71,13 +71,38 @@ impl Owner {
|
|
|
71
71
|
|| expected.receipt.backend!=expected.prepared.backend {return Err(Error::Conflict);}
|
|
72
72
|
Ok(())
|
|
73
73
|
}
|
|
74
|
-
fn
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
74
|
+
fn handoffs(prepared: &Prepared) -> Result<Vec<&crate::egress::LocalLink>> {
|
|
75
|
+
Ok(prepared.policy.scope()?.handoffs.iter().map(|handoff|&handoff.link).collect())
|
|
76
|
+
}
|
|
77
|
+
fn bound(link: &crate::egress::LocalLink) -> wire::BoundInterface<'_> {
|
|
78
|
+
wire::BoundInterface {index:link.interface_index,name:&link.interface_name,kind:&link.interface_kind,
|
|
79
|
+
mac:link.mac_address.as_deref(),link_index:link.interface_link_index,addresses:&link.required_ipv4_addresses}
|
|
80
|
+
}
|
|
81
|
+
/// The handoff links of a transition: those both generations bind exactly,
|
|
82
|
+
/// and those it adds or drops.
|
|
83
|
+
fn fenced<'a>(previous: Option<&'a Prepared>, target: &'a Prepared) -> Result<(Vec<&'a crate::egress::LocalLink>, Vec<&'a crate::egress::LocalLink>)> {
|
|
84
|
+
let previous=previous.map(Self::handoffs).transpose()?.unwrap_or_default();
|
|
85
|
+
let target=Self::handoffs(target)?;
|
|
86
|
+
let kept=target.iter().copied().filter(|link|previous.contains(link)).collect();
|
|
87
|
+
let changed=previous.iter().copied().filter(|link|!target.contains(link))
|
|
88
|
+
.chain(target.iter().copied().filter(|link|!previous.contains(link))).collect();
|
|
89
|
+
Ok((kept,changed))
|
|
90
|
+
}
|
|
91
|
+
/// Reconcile fence, before and after the single restore transaction. A handoff
|
|
92
|
+
/// link both generations bind exactly may carry packets: the transaction swaps
|
|
93
|
+
/// the owned block atomically, so a flow both contributions admit passes
|
|
94
|
+
/// throughout, and the referenced barrier already is the target. Every link
|
|
95
|
+
/// the transition adds or drops must be down.
|
|
96
|
+
fn fence(previous: Option<&Prepared>, target: &Prepared) -> Result<()> {
|
|
97
|
+
let (kept,changed)=Self::fenced(previous,target)?;
|
|
98
|
+
wire::verify_bound_interfaces(kept.into_iter().map(Self::bound))?;
|
|
99
|
+
wire::verify_bound_interfaces_down(changed.into_iter().map(Self::bound))
|
|
100
|
+
}
|
|
101
|
+
/// Cleanup fence: deletion only withdraws admission. A handoff link that no
|
|
102
|
+
/// longer exists, such as a veth that left with its router namespace, counts
|
|
103
|
+
/// as down; one that exists must still be the exact bound link, and down.
|
|
104
|
+
fn cleanup_fence(prepared: &Prepared) -> Result<()> {
|
|
105
|
+
wire::verify_bound_interfaces_down_or_absent(Self::handoffs(prepared)?.into_iter().map(Self::bound))
|
|
81
106
|
}
|
|
82
107
|
fn applied(&self, prepared: Prepared, backend: String) -> Applied {
|
|
83
108
|
Applied {receipt:Receipt {identity:self.identity.clone(),backend,revision:prepared.policy.revision,digest:prepared.digest.clone()},prepared}
|
|
@@ -102,15 +127,14 @@ impl Owner {
|
|
|
102
127
|
return Ok(self.applied(transition.target.clone(),snapshot.backend));
|
|
103
128
|
}
|
|
104
129
|
if !snapshot.verified_matches(&self.options,&previous_rules,deadline)? {return Err(Error::Conflict);}
|
|
105
|
-
|
|
106
|
-
Self::fence(
|
|
130
|
+
let previous=transition.previous.as_ref().map(|v|&v.prepared);
|
|
131
|
+
Self::fence(previous,&transition.target)?;
|
|
107
132
|
frontend::replace(&previous_rules,&target_rules,deadline)?;
|
|
108
133
|
self.context()?;
|
|
109
134
|
let current=frontend::Snapshot::read(deadline)?;
|
|
110
135
|
if current.backend!=snapshot.backend || !current.verified_matches(&self.options,&target_rules,deadline)? {return Err(Error::Conflict);}
|
|
111
136
|
owner::Owner::verify_external(&transition.target.policy.barrier)?;
|
|
112
|
-
|
|
113
|
-
Self::fence(&transition.target)?;
|
|
137
|
+
Self::fence(previous,&transition.target)?;
|
|
114
138
|
Ok(self.applied(transition.target.clone(),current.backend))
|
|
115
139
|
})();
|
|
116
140
|
match result {
|
|
@@ -141,12 +165,12 @@ impl Owner {
|
|
|
141
165
|
if current.verified_matches(&self.options,&[],deadline)? {return Ok(());}
|
|
142
166
|
let rules=expected.prepared.rules(&self.options)?;
|
|
143
167
|
if !current.verified_matches(&self.options,&rules,deadline)? {return Err(Error::Conflict);}
|
|
144
|
-
Self::
|
|
168
|
+
Self::cleanup_fence(&expected.prepared)?;
|
|
145
169
|
frontend::replace(&rules,&[],deadline)?;
|
|
146
170
|
self.context()?;
|
|
147
171
|
let after=frontend::Snapshot::read(deadline)?;
|
|
148
172
|
if after.backend!=current.backend || !after.verified_matches(&self.options,&[],deadline)? {return Err(Error::Conflict);}
|
|
149
|
-
Self::
|
|
173
|
+
Self::cleanup_fence(&expected.prepared)
|
|
150
174
|
})();
|
|
151
175
|
match result {Ok(())=>{self.applied=Some(expected);self.error=None;self.released=true;Ok(())},Err(error)=>{self.error=Some(error);Err(error)}}
|
|
152
176
|
}
|
package/rust/src/docker_tests.rs
CHANGED
|
@@ -2,8 +2,9 @@ use super::*;
|
|
|
2
2
|
use serde_json::json;
|
|
3
3
|
|
|
4
4
|
fn options() -> Options {Options {owner_id:"dockerfixture".into(),instance_id:"qualification_20260913".into()}}
|
|
5
|
-
fn policy() -> policy::Policy {
|
|
6
|
-
|
|
5
|
+
fn policy() -> policy::Policy {policy_from(crate::egress::tests::host())}
|
|
6
|
+
fn policy_from(host: serde_json::Value) -> policy::Policy {
|
|
7
|
+
let prepared:crate::managed::Prepared=serde_json::from_value::<crate::egress::Policy>(host).unwrap().prepare().unwrap().into();
|
|
7
8
|
policy::Policy {schema_version:1,revision:1,barrier:owner::Applied {
|
|
8
9
|
receipt:owner::Receipt {identity:owner::Identity {owner_id:"barrier".into(),instance_id:"qualification_20260913".into(),table_name:"snft_fixture".into(),boot_id:"00000000-0000-4000-8000-000000000000".into(),namespace_device:"4".into(),namespace_inode:"1".into()},table_handle:"1".into(),revision:1,digest:prepared.digest.clone()},prepared}}
|
|
9
10
|
}
|
|
@@ -149,3 +150,161 @@ fn docker_forward_jumps_require_exact_unconditional_order() {
|
|
|
149
150
|
assert!(!graph::matches_forward(&row(expected),if target=="DOCKER-USER" {"DOCKER-FORWARD"} else {"DOCKER-USER"}).unwrap());
|
|
150
151
|
}
|
|
151
152
|
}
|
|
153
|
+
|
|
154
|
+
/// The exact 4.1.0 contribution of a scope without publications: arguments,
|
|
155
|
+
/// order, comments and native graph bytes are unchanged.
|
|
156
|
+
#[test]
|
|
157
|
+
fn docker_leased_contribution_keeps_its_exact_previous_rules_and_graphs() {
|
|
158
|
+
let rules=policy().prepare("v1.8.11 (nf_tables)".into()).unwrap().rules(&options()).unwrap();
|
|
159
|
+
let comment="snftd1:dockerfixture:qualification_20260913:2023fff251e6e68c6ec275e6b5e76f609b8bc0210b18ae3f3c88bfac3a54f5ee";
|
|
160
|
+
let expected=[
|
|
161
|
+
"-i handoff -o ens18 -s 10.240.0.2/32 -p tcp -m tcp --sport 10000:10999 --tcp-flags FIN,SYN,RST,ACK SYN -m conntrack --ctstate NEW --ctproto 6 --ctorigsrc 10.240.0.2 --ctorigsrcport 10000:10999 --ctdir ORIGINAL",
|
|
162
|
+
"-i handoff -o ens18 -s 10.240.0.2/32 -p tcp -m tcp --sport 10000:10999 -m conntrack --ctstate ESTABLISHED --ctproto 6 --ctorigsrc 10.240.0.2 --ctorigsrcport 10000:10999 --ctdir ORIGINAL",
|
|
163
|
+
"-i ens18 -o handoff -d 10.240.0.2/32 -p tcp -m tcp --dport 10000:10999 -m conntrack --ctstate ESTABLISHED --ctproto 6 --ctorigsrc 10.240.0.2 --ctorigsrcport 10000:10999 --ctdir REPLY",
|
|
164
|
+
"-i handoff -o ens18 -s 10.240.0.2/32 -p udp -m udp --sport 10000:10999 -m conntrack --ctstate NEW --ctproto 17 --ctorigsrc 10.240.0.2 --ctorigsrcport 10000:10999 --ctdir ORIGINAL",
|
|
165
|
+
"-i handoff -o ens18 -s 10.240.0.2/32 -p udp -m udp --sport 10000:10999 -m conntrack --ctstate ESTABLISHED --ctproto 17 --ctorigsrc 10.240.0.2 --ctorigsrcport 10000:10999 --ctdir ORIGINAL",
|
|
166
|
+
"-i ens18 -o handoff -d 10.240.0.2/32 -p udp -m udp --dport 10000:10999 -m conntrack --ctstate ESTABLISHED --ctproto 17 --ctorigsrc 10.240.0.2 --ctorigsrcport 10000:10999 --ctdir REPLY",
|
|
167
|
+
];
|
|
168
|
+
assert_eq!(rules.len(),expected.len());
|
|
169
|
+
for (index,(rule,args)) in rules.iter().zip(expected).enumerate() {
|
|
170
|
+
assert_eq!(rule.args.join(" "),format!("{args} -m comment --comment {comment}:{index} -j ACCEPT"));
|
|
171
|
+
}
|
|
172
|
+
let mut digest=<sha2::Sha256 as sha2::Digest>::new();
|
|
173
|
+
for rule in &rules {sha2::Digest::update(&mut digest,crate::wire::encode_attrs(&graph::expected(rule).unwrap()));}
|
|
174
|
+
assert_eq!(format!("{:x}",sha2::Digest::finalize(digest)),"dc46825f57c6f0001c1a517760e9e5560e4a5ee90f00322075c4b96e41e24c79");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
fn published() -> policy::Policy {
|
|
178
|
+
let mut range=crate::egress::tests::published_port("udp",20000,20000);
|
|
179
|
+
range["hostPortEnd"]=json!(20200);range["symmetric"]=json!(true);
|
|
180
|
+
policy_from(crate::egress::tests::published_host(json!([range,crate::egress::tests::published_port("tcp",443,8443)])))
|
|
181
|
+
}
|
|
182
|
+
fn conntrack(expressions: &[crate::wire::Attr]) -> Vec<u8> {
|
|
183
|
+
for expression in expressions {
|
|
184
|
+
let fields=crate::wire::attrs(&expression.value).unwrap();
|
|
185
|
+
if crate::wire::text(&fields,1).unwrap()!="match" {continue;}
|
|
186
|
+
let data=crate::wire::attrs(&crate::wire::one(&fields,2).unwrap().value).unwrap();
|
|
187
|
+
if crate::wire::text(&data,1).unwrap()=="conntrack" {return crate::wire::one(&data,3).unwrap().value.clone();}
|
|
188
|
+
}
|
|
189
|
+
panic!("no conntrack match")
|
|
190
|
+
}
|
|
191
|
+
fn word(bytes: &[u8], offset: usize) -> u16 {u16::from_ne_bytes([bytes[offset],bytes[offset+1]])}
|
|
192
|
+
|
|
193
|
+
/// Inbound publications and their replies, and the flows a symmetric
|
|
194
|
+
/// publication opens from its target ports, as the barrier's forward chain
|
|
195
|
+
/// admits them: leased ranges first, then publications in canonical order.
|
|
196
|
+
#[test]
|
|
197
|
+
fn docker_contribution_admits_every_published_and_symmetric_flow_of_the_barrier() {
|
|
198
|
+
let prepared=published().prepare("v1.8.11 (nf_tables)".into()).unwrap();
|
|
199
|
+
let rules=prepared.rules(&options()).unwrap();
|
|
200
|
+
// 2 leased ranges, 2 inbound publications and 1 symmetric publication, each
|
|
201
|
+
// NEW, ESTABLISHED and the ESTABLISHED reply.
|
|
202
|
+
assert_eq!(rules.len(),15);
|
|
203
|
+
let args=|index: usize|rules[index].args[..rules[index].args.len()-6].join(" ");
|
|
204
|
+
assert_eq!(args(6),"-i ens18 -o handoff -d 10.240.0.2/32 -p tcp -m tcp --dport 8443 --tcp-flags FIN,SYN,RST,ACK SYN -m conntrack --ctstate NEW --ctproto 6 --ctorigdst 192.0.2.2 --ctorigdstport 443 --ctdir ORIGINAL");
|
|
205
|
+
assert_eq!(args(7),"-i ens18 -o handoff -d 10.240.0.2/32 -p tcp -m tcp --dport 8443 -m conntrack --ctstate ESTABLISHED --ctproto 6 --ctorigdst 192.0.2.2 --ctorigdstport 443 --ctdir ORIGINAL");
|
|
206
|
+
assert_eq!(args(8),"-i handoff -o ens18 -s 10.240.0.2/32 -p tcp -m tcp --sport 8443 -m conntrack --ctstate ESTABLISHED --ctproto 6 --ctorigdst 192.0.2.2 --ctorigdstport 443 --ctdir REPLY");
|
|
207
|
+
assert_eq!(args(9),"-i ens18 -o handoff -d 10.240.0.2/32 -p udp -m udp --dport 20000:20200 -m conntrack --ctstate NEW --ctproto 17 --ctorigdst 192.0.2.2 --ctorigdstport 20000:20200 --ctdir ORIGINAL");
|
|
208
|
+
assert_eq!(args(11),"-i handoff -o ens18 -s 10.240.0.2/32 -p udp -m udp --sport 20000:20200 -m conntrack --ctstate ESTABLISHED --ctproto 17 --ctorigdst 192.0.2.2 --ctorigdstport 20000:20200 --ctdir REPLY");
|
|
209
|
+
assert_eq!(args(12),"-i handoff -o ens18 -s 10.240.0.2/32 -p udp -m udp --sport 20000:20200 -m conntrack --ctstate NEW --ctproto 17 --ctorigsrc 10.240.0.2 --ctorigsrcport 20000:20200 --ctdir ORIGINAL");
|
|
210
|
+
assert_eq!(args(13),"-i handoff -o ens18 -s 10.240.0.2/32 -p udp -m udp --sport 20000:20200 -m conntrack --ctstate ESTABLISHED --ctproto 17 --ctorigsrc 10.240.0.2 --ctorigsrcport 20000:20200 --ctdir ORIGINAL");
|
|
211
|
+
assert_eq!(args(14),"-i ens18 -o handoff -d 10.240.0.2/32 -p udp -m udp --dport 20000:20200 -m conntrack --ctstate ESTABLISHED --ctproto 17 --ctorigsrc 10.240.0.2 --ctorigsrcport 20000:20200 --ctdir REPLY");
|
|
212
|
+
// Without publications only the leased rules remain; a non-symmetric
|
|
213
|
+
// publication opens nothing from its target ports.
|
|
214
|
+
let mut plain=crate::egress::tests::published_port("udp",20000,20000);plain["hostPortEnd"]=json!(20200);
|
|
215
|
+
let plain=policy_from(crate::egress::tests::published_host(json!([plain]))).prepare("v1.8.11 (nf_tables)".into()).unwrap().rules(&options()).unwrap();
|
|
216
|
+
assert_eq!(plain.len(),9);
|
|
217
|
+
assert!(plain.iter().all(|rule|!rule.args.windows(2).any(|w|w==["--ctorigsrcport","20000:20200"])));
|
|
218
|
+
assert!(snapshot(&text(&rules)).matches(&options(),&rules).unwrap());
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
#[test]
|
|
222
|
+
fn docker_inbound_graph_binds_the_original_destination_tuple() {
|
|
223
|
+
let rules=published().prepare("v1.8.11 (nf_tables)".into()).unwrap().rules(&options()).unwrap();
|
|
224
|
+
for (index,reply) in [(6,false),(8,true)] {
|
|
225
|
+
let expressions=graph::expected(&rules[index]).unwrap();
|
|
226
|
+
let data=conntrack(&expressions);
|
|
227
|
+
assert_eq!(data.len(),168);
|
|
228
|
+
assert!(data[..32].iter().all(|byte|*byte==0));
|
|
229
|
+
assert_eq!(&data[32..36],&[192,0,2,2]);
|
|
230
|
+
assert!(data[36..48].iter().all(|byte|*byte==0) && data[48..64].iter().all(|byte|*byte==0xff));
|
|
231
|
+
assert_eq!((word(&data,136),word(&data,138),word(&data,140),word(&data,146),word(&data,148),word(&data,150)),
|
|
232
|
+
(6,0,443,0x120b,if reply {0x1000} else {0},if reply {2} else {8}));
|
|
233
|
+
assert_eq!((word(&data,154),word(&data,156)),(0,443));
|
|
234
|
+
// The live tuple: the translated inside destination, or its reply source.
|
|
235
|
+
let payload=crate::wire::attrs(&expressions[0].value).unwrap();
|
|
236
|
+
let fields=crate::wire::attrs(&crate::wire::one(&payload,2).unwrap().value).unwrap();
|
|
237
|
+
assert_eq!(crate::wire::one(&fields,3).unwrap().value,(if reply {12_u32} else {16}).to_be_bytes());
|
|
238
|
+
let row=|expressions|vec![crate::wire::Attr::string(1,"filter"),crate::wire::Attr::string(2,"DOCKER-USER"),
|
|
239
|
+
crate::wire::Attr::u64(3,42),crate::wire::Attr::nested(4,expressions)];
|
|
240
|
+
assert!(graph::matches_rule(&row(expressions.clone()),&rules[index]).unwrap());
|
|
241
|
+
let mut hidden=expressions.clone();
|
|
242
|
+
for expression in &mut hidden {
|
|
243
|
+
let mut fields=crate::wire::attrs(&expression.value).unwrap();
|
|
244
|
+
if crate::wire::text(&fields,1).unwrap()!="match" {continue;}
|
|
245
|
+
let mut data=crate::wire::attrs(&crate::wire::one(&fields,2).unwrap().value).unwrap();
|
|
246
|
+
if crate::wire::text(&data,1).unwrap()!="conntrack" {continue;}
|
|
247
|
+
data.iter_mut().find(|a|a.id()==3).unwrap().value[52..64].fill(0);
|
|
248
|
+
*fields.iter_mut().find(|a|a.id()==2).unwrap()=crate::wire::Attr::nested(2,data);
|
|
249
|
+
expression.value=crate::wire::encode_attrs(&fields);
|
|
250
|
+
}
|
|
251
|
+
assert!(!graph::matches_rule(&row(hidden),&rules[index]).unwrap());
|
|
252
|
+
}
|
|
253
|
+
// A ranged inbound publication binds its whole outside span.
|
|
254
|
+
let data=conntrack(&graph::expected(&rules[9]).unwrap());
|
|
255
|
+
assert_eq!((word(&data,140),word(&data,156)),(20000,20200));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
#[test]
|
|
259
|
+
fn docker_snapshot_accepts_rendered_destination_tuples_and_rejects_changed_ones() {
|
|
260
|
+
let rules=published().prepare("v1.8.11 (nf_tables)".into()).unwrap().rules(&options()).unwrap();
|
|
261
|
+
let original=text(&rules);
|
|
262
|
+
assert!(snapshot(&original.replace("--ctorigdst 192.0.2.2 ","--ctorigdst 192.0.2.2/32 ")).matches(&options(),&rules).unwrap());
|
|
263
|
+
for (before,after) in [("--ctorigdstport 443","--ctorigdstport 444"),("--ctorigdst 192.0.2.2","--ctorigsrc 192.0.2.2"),
|
|
264
|
+
("--dport 8443","--dport 8444"),("--ctorigdst 192.0.2.2","--ctorigdst 192.0.2.3")] {
|
|
265
|
+
assert!(!snapshot(&original.replacen(before,after,1)).matches(&options(),&rules).unwrap());
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
#[test]
|
|
270
|
+
fn docker_contribution_keeps_its_restore_bound() {
|
|
271
|
+
let ports:Vec<_>=(0..64_u16).map(|index|crate::egress::tests::published_port("tcp",443+index,443+index)).collect();
|
|
272
|
+
let error=policy_from(crate::egress::tests::published_host(json!(ports))).prepare("v1.8.11 (nf_tables)".into()).unwrap_err();
|
|
273
|
+
assert_eq!(error.code(),"EXHAUSTED");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
fn absent(mut host: serde_json::Value) -> serde_json::Value {
|
|
277
|
+
// No link holds this index on a test host.
|
|
278
|
+
host["scope"]["handoffs"][0]["link"]["interfaceIndex"]=json!(2_147_483_000_u32);host
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
#[test]
|
|
282
|
+
fn docker_reconcile_fence_lets_only_links_both_generations_bind_carry_packets() {
|
|
283
|
+
let target=policy().prepare("v1.8.11 (nf_tables)".into()).unwrap();
|
|
284
|
+
let amended=published().prepare("v1.8.11 (nf_tables)".into()).unwrap();
|
|
285
|
+
let moved=policy_from(absent(crate::egress::tests::host())).prepare("v1.8.11 (nf_tables)".into()).unwrap();
|
|
286
|
+
let names=|links: Vec<&crate::egress::LocalLink>|links.iter().map(|link|link.interface_index).collect::<Vec<_>>();
|
|
287
|
+
let (kept,changed)=Owner::fenced(None,&target).unwrap();
|
|
288
|
+
assert_eq!((names(kept),names(changed)),(vec![],vec![4]));
|
|
289
|
+
let (kept,changed)=Owner::fenced(Some(&target),&amended).unwrap();
|
|
290
|
+
assert_eq!((names(kept),names(changed)),(vec![4],vec![]));
|
|
291
|
+
let (kept,changed)=Owner::fenced(Some(&target),&moved).unwrap();
|
|
292
|
+
assert_eq!((names(kept),names(changed)),(vec![],vec![4,2_147_483_000]));
|
|
293
|
+
// A link the transition keeps must still exist exactly.
|
|
294
|
+
assert!(Owner::fence(Some(&moved),&moved).is_err());
|
|
295
|
+
assert!(Owner::fence(None,&moved).is_err());
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
#[test]
|
|
299
|
+
fn docker_cleanup_fence_counts_a_vanished_handoff_as_down() {
|
|
300
|
+
let gone=policy_from(absent(crate::egress::tests::host())).prepare("v1.8.11 (nf_tables)".into()).unwrap();
|
|
301
|
+
Owner::cleanup_fence(&gone).unwrap();
|
|
302
|
+
// The mutation fence still requires the exact link.
|
|
303
|
+
let link=&gone.policy.scope().unwrap().handoffs[0].link;
|
|
304
|
+
assert!(crate::wire::verify_bound_interfaces_down(std::iter::once(Owner::bound(link))).is_err());
|
|
305
|
+
// A different link at a bound index is not the bound link.
|
|
306
|
+
let mut loopback=crate::egress::tests::host();
|
|
307
|
+
loopback["scope"]["handoffs"][0]["link"]["interfaceIndex"]=json!(1);
|
|
308
|
+
let loopback=policy_from(loopback).prepare("v1.8.11 (nf_tables)".into()).unwrap();
|
|
309
|
+
assert_eq!(Owner::cleanup_fence(&loopback),Err(Error::Conflict));
|
|
310
|
+
}
|
package/rust/src/egress.host.rs
CHANGED
|
@@ -84,34 +84,31 @@ fn leased(
|
|
|
84
84
|
expressions.extend(original_range(range.first, range.last));
|
|
85
85
|
Ok(expressions)
|
|
86
86
|
}
|
|
87
|
-
/// The publications of this hop
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
Ok(published::Hop {
|
|
87
|
+
/// The publications of this hop, from the scope's forwarding enumeration.
|
|
88
|
+
fn hop<'a>(scope: &'a HostScope, forwarding: &Forwarding<'a>) -> published::Hop<'a> {
|
|
89
|
+
let publications = forwarding
|
|
90
|
+
.published
|
|
91
|
+
.iter()
|
|
92
|
+
.map(|item| published::Publication {
|
|
93
|
+
protocol: &item.port.protocol,
|
|
94
|
+
outside: (
|
|
95
|
+
&item.port.host_ip,
|
|
96
|
+
item.port.host_port,
|
|
97
|
+
item.port.host_last(),
|
|
98
|
+
),
|
|
99
|
+
inside: (
|
|
100
|
+
&item.port.target_address,
|
|
101
|
+
item.port.target_port,
|
|
102
|
+
item.port.target_last(),
|
|
103
|
+
),
|
|
104
|
+
link: item.link,
|
|
105
|
+
symmetric: item.port.symmetric,
|
|
106
|
+
})
|
|
107
|
+
.collect();
|
|
108
|
+
published::Hop {
|
|
112
109
|
publications,
|
|
113
110
|
fixed: &scope.uplink,
|
|
114
|
-
}
|
|
111
|
+
}
|
|
115
112
|
}
|
|
116
113
|
/// The exact platform endpoints served on host addresses. Every packet is
|
|
117
114
|
/// checked against the live and the originally tracked destination tuple, so a
|
|
@@ -219,7 +216,8 @@ pub(super) fn compile(program: &mut Program<'_>, scope: &HostScope) -> Result<()
|
|
|
219
216
|
.ok_or(crate::Error::Invalid)
|
|
220
217
|
})?;
|
|
221
218
|
}
|
|
222
|
-
let
|
|
219
|
+
let forwarding = scope.forwarding()?;
|
|
220
|
+
let hop = hop(scope, &forwarding);
|
|
223
221
|
published::sets(program, &hop, false)?;
|
|
224
222
|
protection(program, scope, false)?;
|
|
225
223
|
protection(program, scope, true)?;
|
|
@@ -241,26 +239,27 @@ pub(super) fn compile(program: &mut Program<'_>, scope: &HostScope) -> Result<()
|
|
|
241
239
|
program.jump("forward", link(&handoff.link, true), "protected_out")?;
|
|
242
240
|
program.jump("forward", link(&handoff.link, false), "protected_back")?;
|
|
243
241
|
}
|
|
244
|
-
for
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
let mut reply = envelope(scope, handoff, allocation, range, true)?;
|
|
257
|
-
reply.extend(state(2));
|
|
258
|
-
program.end("forward", reply, 1)?;
|
|
259
|
-
let mut nat = original;
|
|
260
|
-
nat.extend(snat(&scope.snat_address, None)?);
|
|
261
|
-
program.rule("post", nat)?;
|
|
242
|
+
for LeasedForward {
|
|
243
|
+
handoff,
|
|
244
|
+
allocation,
|
|
245
|
+
range,
|
|
246
|
+
} in &forwarding.leased
|
|
247
|
+
{
|
|
248
|
+
let original = envelope(scope, handoff, allocation, range, false)?;
|
|
249
|
+
for connection_state in [2, 8] {
|
|
250
|
+
let mut expressions = original.clone();
|
|
251
|
+
expressions.extend(state(connection_state));
|
|
252
|
+
if connection_state == 8 && range.protocol == "tcp" {
|
|
253
|
+
expressions.extend(opening_tcp());
|
|
262
254
|
}
|
|
255
|
+
program.end("forward", expressions, 1)?;
|
|
263
256
|
}
|
|
257
|
+
let mut reply = envelope(scope, handoff, allocation, range, true)?;
|
|
258
|
+
reply.extend(state(2));
|
|
259
|
+
program.end("forward", reply, 1)?;
|
|
260
|
+
let mut nat = original;
|
|
261
|
+
nat.extend(snat(&scope.snat_address, None)?);
|
|
262
|
+
program.rule("post", nat)?;
|
|
264
263
|
}
|
|
265
264
|
// Symmetric flows leave from ports outside every leased range of their
|
|
266
265
|
// address, after the capture barrier and before the terminal handoff denials.
|
package/rust/src/egress.rs
CHANGED
|
@@ -216,6 +216,62 @@ pub struct HostScope {
|
|
|
216
216
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
|
217
217
|
pub exclusive_forwarding: bool,
|
|
218
218
|
}
|
|
219
|
+
/// One leased source port range of one allocation, on the handoff that holds it.
|
|
220
|
+
pub struct LeasedForward<'a> {
|
|
221
|
+
pub handoff: &'a HostHandoff,
|
|
222
|
+
pub allocation: &'a Allocation,
|
|
223
|
+
pub range: &'a PortRange,
|
|
224
|
+
}
|
|
225
|
+
/// One publication, on the handoff link that holds its leased target address.
|
|
226
|
+
pub struct PublishedForward<'a> {
|
|
227
|
+
pub port: &'a PublishedPort,
|
|
228
|
+
pub link: &'a LocalLink,
|
|
229
|
+
}
|
|
230
|
+
/// The forwarded flows a host-transit scope admits, in canonical scope order:
|
|
231
|
+
/// leased flows from a handoff to the uplink, and per publication its inbound
|
|
232
|
+
/// flows from the uplink to the handoff and, when symmetric, the flows it opens
|
|
233
|
+
/// from its target ports to the uplink; each with its replies. The scope's nft
|
|
234
|
+
/// forward chain and the Docker contribution both derive from this one list.
|
|
235
|
+
pub struct Forwarding<'a> {
|
|
236
|
+
pub leased: Vec<LeasedForward<'a>>,
|
|
237
|
+
pub published: Vec<PublishedForward<'a>>,
|
|
238
|
+
}
|
|
239
|
+
impl HostScope {
|
|
240
|
+
pub fn forwarding(&self) -> Result<Forwarding<'_>> {
|
|
241
|
+
let mut leased = Vec::new();
|
|
242
|
+
for handoff in &self.handoffs {
|
|
243
|
+
for allocation in &handoff.allocations {
|
|
244
|
+
for range in &allocation.source_port_ranges {
|
|
245
|
+
leased.push(LeasedForward {
|
|
246
|
+
handoff,
|
|
247
|
+
allocation,
|
|
248
|
+
range,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// Validation admits only leased transit source addresses as targets, and
|
|
254
|
+
// one address never spans two active handoff links, so each publication
|
|
255
|
+
// has exactly one handoff.
|
|
256
|
+
let mut published = Vec::new();
|
|
257
|
+
for port in &self.published_ports {
|
|
258
|
+
let handoff =
|
|
259
|
+
self.handoffs
|
|
260
|
+
.iter()
|
|
261
|
+
.find(|handoff| {
|
|
262
|
+
handoff.allocations.iter().any(|allocation| {
|
|
263
|
+
allocation.transit_source_address == port.target_address
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
.ok_or(Error::Invalid)?;
|
|
267
|
+
published.push(PublishedForward {
|
|
268
|
+
port,
|
|
269
|
+
link: &handoff.link,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
Ok(Forwarding { leased, published })
|
|
273
|
+
}
|
|
274
|
+
}
|
|
219
275
|
/// One loopback TCP service that only one local user may dial: packets the host
|
|
220
276
|
/// sends to the exact address and port from a socket of any other user reject,
|
|
221
277
|
/// and packets arriving there on any interface but loopback drop.
|