@push.rocks/smartnftables 1.4.0 → 1.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.
Files changed (53) hide show
  1. package/changelog.md +26 -0
  2. package/dist_rust/smartnftables_linux_amd64_musl +0 -0
  3. package/dist_rust/smartnftables_linux_amd64_musl.tsrust-build.json +4 -4
  4. package/dist_rust/smartnftables_linux_arm64_musl +0 -0
  5. package/dist_rust/smartnftables_linux_arm64_musl.tsrust-build.json +4 -4
  6. package/dist_ts/00_commitinfo_data.js +1 -1
  7. package/dist_ts/classes.manageddockerforwarding.d.ts +17 -0
  8. package/dist_ts/classes.manageddockerforwarding.js +128 -0
  9. package/dist_ts/classes.managednftables.d.ts +9 -7
  10. package/dist_ts/classes.managednftables.js +2 -2
  11. package/dist_ts/index.d.ts +3 -0
  12. package/dist_ts/index.js +3 -1
  13. package/dist_ts/managed.docker.types.d.ts +78 -0
  14. package/dist_ts/managed.docker.types.js +2 -0
  15. package/dist_ts/managed.egress.types.d.ts +91 -0
  16. package/dist_ts/managed.egress.types.js +2 -0
  17. package/dist_ts/managed.types.d.ts +20 -18
  18. package/package.json +2 -2
  19. package/readme.md +143 -6
  20. package/rust/src/docker.frontend.rs +203 -0
  21. package/rust/src/docker.graph.rs +129 -0
  22. package/rust/src/docker.policy.rs +140 -0
  23. package/rust/src/docker.rs +191 -0
  24. package/rust/src/docker_process_tests.rs +29 -0
  25. package/rust/src/docker_tests.rs +148 -0
  26. package/rust/src/egress.compile.rs +288 -0
  27. package/rust/src/egress.host.rs +103 -0
  28. package/rust/src/egress.router.rs +231 -0
  29. package/rust/src/egress.rs +682 -0
  30. package/rust/src/egress_tests.rs +332 -0
  31. package/rust/src/main.rs +13 -6
  32. package/rust/src/managed.rs +105 -0
  33. package/rust/src/owner.rs +115 -7
  34. package/rust/src/owner_coexistence_tests.rs +77 -0
  35. package/rust/src/owner_egress_identity_tests.rs +155 -0
  36. package/rust/src/owner_egress_tests.rs +153 -0
  37. package/rust/src/owner_egress_traffic_tests.rs +470 -0
  38. package/rust/src/owner_host_traffic_tests.rs +305 -0
  39. package/rust/src/owner_identity_tests.rs +169 -0
  40. package/rust/src/owner_link_tests.rs +119 -0
  41. package/rust/src/owner_packet_fixture.rs +200 -0
  42. package/rust/src/owner_tests.rs +33 -7
  43. package/rust/src/policy.rs +120 -81
  44. package/rust/src/tests.rs +22 -0
  45. package/rust/src/wire.links.rs +279 -0
  46. package/rust/src/wire.rs +16 -66
  47. package/ts/00_commitinfo_data.ts +1 -1
  48. package/ts/classes.manageddockerforwarding.ts +113 -0
  49. package/ts/classes.managednftables.ts +13 -13
  50. package/ts/index.ts +3 -0
  51. package/ts/managed.docker.types.ts +49 -0
  52. package/ts/managed.egress.types.ts +85 -0
  53. package/ts/managed.types.ts +20 -16
@@ -0,0 +1,140 @@
1
+ use crate::{egress, managed, owner, policy, Error, Result};
2
+ use serde::{Deserialize, Serialize};
3
+ use sha2::{Digest, Sha256};
4
+
5
+ #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
6
+ #[serde(rename_all = "camelCase", deny_unknown_fields)]
7
+ pub struct Policy {
8
+ pub schema_version: u8,
9
+ pub revision: u64,
10
+ pub barrier: owner::Applied,
11
+ }
12
+ #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
13
+ #[serde(rename_all = "camelCase", deny_unknown_fields)]
14
+ pub struct Prepared {
15
+ pub policy: Policy,
16
+ pub backend: String,
17
+ pub digest: String,
18
+ }
19
+ pub fn validate_backend(value: &str) -> Result<()> {
20
+ let patch=value.strip_prefix("v1.8.").and_then(|v|v.strip_suffix(" (nf_tables)")).ok_or(Error::UnsupportedBackend)?;
21
+ let number=patch.parse::<u16>().map_err(|_|Error::UnsupportedBackend)?;
22
+ if number < 10 || number.to_string()!=patch {return Err(Error::UnsupportedBackend);}
23
+ Ok(())
24
+ }
25
+ #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
26
+ #[serde(rename_all = "camelCase", deny_unknown_fields)]
27
+ pub struct Options {
28
+ pub owner_id: String,
29
+ pub instance_id: String,
30
+ }
31
+ #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
32
+ #[serde(rename_all = "camelCase", deny_unknown_fields)]
33
+ pub struct Identity {
34
+ pub owner_id: String,
35
+ pub instance_id: String,
36
+ pub boot_id: String,
37
+ pub namespace_device: String,
38
+ pub namespace_inode: String,
39
+ }
40
+ impl Options {
41
+ pub fn validate(&self) -> Result<()> {
42
+ if !policy::identifier(&self.owner_id) || !policy::identifier(&self.instance_id) || self.instance_id.len() < 16 {
43
+ return Err(Error::Invalid);
44
+ }
45
+ Ok(())
46
+ }
47
+ pub fn identity(&self) -> Result<Identity> {
48
+ self.validate()?;
49
+ let (boot_id, namespace_device, namespace_inode) = owner::context()?;
50
+ Ok(Identity { owner_id:self.owner_id.clone(), instance_id:self.instance_id.clone(), boot_id, namespace_device, namespace_inode })
51
+ }
52
+ }
53
+ impl Policy {
54
+ pub fn scope(&self) -> Result<&egress::HostScope> {
55
+ match &self.barrier.prepared.policy {
56
+ managed::Policy::Egress(egress::Policy { scope:egress::Scope::HostTransit(scope), .. }) => Ok(scope),
57
+ _ => Err(Error::Invalid),
58
+ }
59
+ }
60
+ pub fn prepare(self, backend: String) -> Result<Prepared> {
61
+ validate_backend(&backend)?;
62
+ if self.schema_version != 1 || self.revision == 0 || self.revision > 9_007_199_254_740_991 {
63
+ return Err(Error::Invalid);
64
+ }
65
+ self.barrier.prepared.validate()?;
66
+ self.scope()?;
67
+ let receipt = &self.barrier.receipt;
68
+ let handle = receipt.table_handle.parse::<u64>().map_err(|_| Error::Invalid)?;
69
+ if handle == 0 || handle.to_string() != receipt.table_handle
70
+ || receipt.revision != self.barrier.prepared.policy.revision()
71
+ || receipt.digest != self.barrier.prepared.digest
72
+ || !policy::identifier(&receipt.identity.owner_id)
73
+ || !policy::identifier(&receipt.identity.instance_id)
74
+ || receipt.identity.instance_id.len() < 16
75
+ || !policy::identifier(&receipt.identity.table_name)
76
+ || !receipt.identity.table_name.starts_with("snft_") {
77
+ return Err(Error::Invalid);
78
+ }
79
+ let mut hash = Sha256::new();
80
+ hash.update(b"smartnftables/docker-forwarding-v1\0");
81
+ hash.update(backend.as_bytes());
82
+ hash.update([0]);
83
+ hash.update(serde_json::to_vec(&self).map_err(|_| Error::Invalid)?);
84
+ let prepared = Prepared { policy:self, backend, digest:format!("sha256:{:x}",hash.finalize()) };
85
+ // Bound the fully expanded restore transaction before any subprocess.
86
+ prepared.rules(&Options { owner_id:"x".repeat(64), instance_id:"x".repeat(64) })?;
87
+ Ok(prepared)
88
+ }
89
+ }
90
+ #[derive(Clone, Debug, PartialEq, Eq)]
91
+ pub struct Rule {
92
+ pub comment: String,
93
+ pub args: Vec<String>,
94
+ }
95
+ fn port_range(first: u16, last: u16) -> String {
96
+ if first == last { first.to_string() } else { format!("{first}:{last}") }
97
+ }
98
+ impl Prepared {
99
+ pub fn validate(&self) -> Result<()> {
100
+ if self.policy.clone().prepare(self.backend.clone())? != *self { return Err(Error::Invalid); }
101
+ Ok(())
102
+ }
103
+ pub fn rules(&self, options: &Options) -> Result<Vec<Rule>> {
104
+ options.validate()?;
105
+ let scope = self.policy.scope()?;
106
+ let mut result = Vec::new();
107
+ let mut bytes = 0;
108
+ for handoff in &scope.handoffs {
109
+ for allocation in &handoff.allocations {
110
+ for range in &allocation.source_port_ranges {
111
+ for state in ["NEW", "ESTABLISHED", "REPLY"] {
112
+ let reply = state == "REPLY";
113
+ let mut args: Vec<String> = vec![
114
+ "-i".into(), if reply { scope.uplink.interface_name.clone() } else { handoff.link.interface_name.clone() },
115
+ "-o".into(), if reply { handoff.link.interface_name.clone() } else { scope.uplink.interface_name.clone() },
116
+ if reply { "-d" } else { "-s" }.into(), format!("{}/32", allocation.transit_source_address),
117
+ "-p".into(),range.protocol.clone(),"-m".into(),range.protocol.clone(),
118
+ if reply { "--dport" } else { "--sport" }.into(),port_range(range.first,range.last),
119
+ ];
120
+ if state == "NEW" && range.protocol == "tcp" {
121
+ args.extend(["--tcp-flags","FIN,SYN,RST,ACK","SYN"].map(String::from));
122
+ }
123
+ args.extend(["-m","conntrack","--ctstate",if reply {"ESTABLISHED"} else {state},"--ctproto",if range.protocol == "tcp" {"6"} else {"17"},"--ctorigsrc"].map(String::from));
124
+ // Canonical xtables host form: explicit /32 has different unused
125
+ // conntrack mask bytes and does not survive a save/restore round trip.
126
+ args.push(allocation.transit_source_address.clone());
127
+ args.push("--ctorigsrcport".into());args.push(port_range(range.first,range.last));
128
+ args.extend(["--ctdir",if reply {"REPLY"} else {"ORIGINAL"},"-m","comment","--comment"].map(String::from));
129
+ let comment = format!("snftd1:{}:{}:{}:{}",options.owner_id,options.instance_id,self.digest.strip_prefix("sha256:").ok_or(Error::Invalid)?,result.len());
130
+ args.push(comment.clone());args.extend(["-j","ACCEPT"].map(String::from));
131
+ bytes += args.iter().map(|arg|arg.len()+1).sum::<usize>()+32;
132
+ if result.len() >= 192 || bytes > 100_000 { return Err(Error::Invalid); }
133
+ result.push(Rule {comment,args});
134
+ }
135
+ }
136
+ }
137
+ }
138
+ Ok(result)
139
+ }
140
+ }
@@ -0,0 +1,191 @@
1
+ use crate::{decode, encode, owner, wire, Error, Request, Result};
2
+ use serde::{Deserialize, Serialize};
3
+ #[path = "docker.policy.rs"]
4
+ mod policy;
5
+ #[path = "docker.frontend.rs"]
6
+ mod frontend;
7
+ #[path = "docker.graph.rs"]
8
+ mod graph;
9
+ use policy::{Identity, Options, Prepared};
10
+ #[cfg(test)]
11
+ #[path="docker_tests.rs"]
12
+ mod tests;
13
+
14
+ #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
15
+ #[serde(rename_all="camelCase",deny_unknown_fields)]
16
+ pub struct Receipt {
17
+ pub identity: Identity,
18
+ pub backend: String,
19
+ pub revision: u64,
20
+ pub digest: String,
21
+ }
22
+ #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
23
+ #[serde(rename_all="camelCase",deny_unknown_fields)]
24
+ pub struct Applied { pub receipt: Receipt, pub prepared: Prepared }
25
+ #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
26
+ #[serde(rename_all="camelCase",deny_unknown_fields)]
27
+ pub struct Transition {
28
+ #[serde(deserialize_with="crate::policy::required_nullable")]
29
+ pub previous: Option<Applied>,
30
+ pub target: Prepared,
31
+ }
32
+ #[derive(Clone, Debug, Serialize)]
33
+ #[serde(rename_all="camelCase")]
34
+ pub struct Status {
35
+ pub identity: Identity,
36
+ pub state: String,
37
+ pub present: bool,
38
+ pub applied: Option<Applied>,
39
+ pub pending: Option<Transition>,
40
+ pub error: Option<String>,
41
+ }
42
+ pub struct Owner {
43
+ options: Options,
44
+ identity: Identity,
45
+ applied: Option<Applied>,
46
+ pending: Option<Transition>,
47
+ error: Option<Error>,
48
+ released: bool,
49
+ }
50
+ impl Owner {
51
+ fn new(options: Options) -> Result<Self> {
52
+ let identity=options.identity()?;
53
+ Ok(Self {options,identity,applied:None,pending:None,error:None,released:false})
54
+ }
55
+ fn context(&self) -> Result<()> {
56
+ if self.options.identity()? != self.identity {return Err(Error::Conflict);}
57
+ Ok(())
58
+ }
59
+ fn validate_target(&self, target: &Prepared) -> Result<()> {
60
+ target.validate()?;
61
+ self.context()?;
62
+ let identity=&target.policy.barrier.receipt.identity;
63
+ if identity.boot_id!=self.identity.boot_id || identity.namespace_device!=self.identity.namespace_device
64
+ || identity.namespace_inode!=self.identity.namespace_inode {return Err(Error::Conflict);}
65
+ Ok(())
66
+ }
67
+ fn validate_receipt(&self, expected: &Applied) -> Result<()> {
68
+ self.validate_target(&expected.prepared)?;
69
+ if expected.receipt.identity!=self.identity || expected.receipt.digest!=expected.prepared.digest
70
+ || expected.receipt.revision!=expected.prepared.policy.revision
71
+ || expected.receipt.backend!=expected.prepared.backend {return Err(Error::Conflict);}
72
+ Ok(())
73
+ }
74
+ fn fence(target: &Prepared) -> Result<()> {
75
+ let scope=target.policy.scope()?;
76
+ wire::verify_bound_interfaces_down(scope.handoffs.iter().map(|handoff| {
77
+ let link=&handoff.link;
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
+ }
82
+ fn applied(&self, prepared: Prepared, backend: String) -> Applied {
83
+ Applied {receipt:Receipt {identity:self.identity.clone(),backend,revision:prepared.policy.revision,digest:prepared.digest.clone()},prepared}
84
+ }
85
+ fn reconcile(&mut self, transition: Transition, deadline: std::time::Instant) -> Result<Applied> {
86
+ self.validate_target(&transition.target)?;
87
+ if let Some(previous)=&transition.previous {
88
+ self.validate_receipt(previous)?;
89
+ if previous.prepared.policy.revision>=transition.target.policy.revision {return Err(Error::Conflict);}
90
+ }
91
+ if self.released || self.pending.as_ref().is_some_and(|v|v!=&transition)
92
+ || self.applied.as_ref().is_some_and(|v|Some(v)!=transition.previous.as_ref() && v.prepared!=transition.target) {return Err(Error::Conflict);}
93
+ self.pending=Some(transition.clone());
94
+ let result=(|| {
95
+ let target_rules=transition.target.rules(&self.options)?;
96
+ let previous_rules=transition.previous.as_ref().map(|v|v.prepared.rules(&self.options)).transpose()?.unwrap_or_default();
97
+ let snapshot=frontend::Snapshot::read(deadline)?;
98
+ if snapshot.backend!=transition.target.backend {return Err(Error::Conflict);}
99
+ if transition.previous.as_ref().is_some_and(|v|v.receipt.backend!=snapshot.backend) {return Err(Error::Conflict);}
100
+ owner::Owner::verify_external(&transition.target.policy.barrier)?;
101
+ if snapshot.verified_matches(&self.options,&target_rules,deadline)? {
102
+ return Ok(self.applied(transition.target.clone(),snapshot.backend));
103
+ }
104
+ if !snapshot.verified_matches(&self.options,&previous_rules,deadline)? {return Err(Error::Conflict);}
105
+ if let Some(previous)=&transition.previous {Self::fence(&previous.prepared)?;}
106
+ Self::fence(&transition.target)?;
107
+ frontend::replace(&previous_rules,&target_rules,deadline)?;
108
+ self.context()?;
109
+ let current=frontend::Snapshot::read(deadline)?;
110
+ if current.backend!=snapshot.backend || !current.verified_matches(&self.options,&target_rules,deadline)? {return Err(Error::Conflict);}
111
+ owner::Owner::verify_external(&transition.target.policy.barrier)?;
112
+ if let Some(previous)=&transition.previous {Self::fence(&previous.prepared)?;}
113
+ Self::fence(&transition.target)?;
114
+ Ok(self.applied(transition.target.clone(),current.backend))
115
+ })();
116
+ match result {
117
+ Ok(applied)=>{self.applied=Some(applied.clone());self.pending=None;self.error=None;Ok(applied)},
118
+ Err(error)=>{self.error=Some(error);Err(error)},
119
+ }
120
+ }
121
+ fn inspect(&mut self, deadline: std::time::Instant) -> Status {
122
+ let mut present=false;
123
+ if let Some(applied)=self.applied.clone().filter(|_|!self.released) {
124
+ let result=(|| {
125
+ self.validate_receipt(&applied)?;
126
+ let current=frontend::Snapshot::read(deadline)?;
127
+ if current.backend!=applied.receipt.backend || !current.verified_matches(&self.options,&applied.prepared.rules(&self.options)?,deadline)? {return Err(Error::Conflict);}
128
+ owner::Owner::verify_external(&applied.prepared.policy.barrier)
129
+ })();
130
+ match result {Ok(())=>present=self.pending.is_none()&&self.error.is_none(),Err(error)=>self.error=Some(error)}
131
+ }
132
+ Status {identity:self.identity.clone(),state:if self.released {"released"} else if self.pending.is_some()||self.error.is_some() {"failed-owned"} else if present {"applied"} else {"idle"}.into(),
133
+ present,applied:self.applied.clone(),pending:self.pending.clone(),error:self.error.map(|v|v.code().into())}
134
+ }
135
+ fn release(&mut self, expected: Applied, deadline: std::time::Instant) -> Result<()> {
136
+ self.validate_receipt(&expected)?;
137
+ if self.pending.is_some() || self.applied.as_ref().is_some_and(|v|v!=&expected) {return Err(Error::Conflict);}
138
+ let result=(|| {
139
+ let current=frontend::Snapshot::read(deadline)?;
140
+ if current.backend!=expected.receipt.backend {return Err(Error::Conflict);}
141
+ if current.verified_matches(&self.options,&[],deadline)? {return Ok(());}
142
+ let rules=expected.prepared.rules(&self.options)?;
143
+ if !current.verified_matches(&self.options,&rules,deadline)? {return Err(Error::Conflict);}
144
+ Self::fence(&expected.prepared)?;
145
+ frontend::replace(&rules,&[],deadline)?;
146
+ self.context()?;
147
+ let after=frontend::Snapshot::read(deadline)?;
148
+ if after.backend!=current.backend || !after.verified_matches(&self.options,&[],deadline)? {return Err(Error::Conflict);}
149
+ Self::fence(&expected.prepared)
150
+ })();
151
+ match result {Ok(())=>{self.applied=Some(expected);self.error=None;self.released=true;Ok(())},Err(error)=>{self.error=Some(error);Err(error)}}
152
+ }
153
+ fn close(&mut self, deadline: std::time::Instant) -> Result<()> {
154
+ if let Some(pending)=self.pending.clone() {
155
+ self.context()?;
156
+ let current=frontend::Snapshot::read(deadline)?;
157
+ if current.backend!=pending.target.backend {return Err(Error::Conflict);}
158
+ let known=if current.verified_matches(&self.options,&pending.target.rules(&self.options)?,deadline)? {
159
+ Some(self.applied(pending.target,current.backend.clone()))
160
+ } else if let Some(previous)=pending.previous {
161
+ if current.backend!=previous.receipt.backend || !current.verified_matches(&self.options,&previous.prepared.rules(&self.options)?,deadline)? {return Err(Error::Conflict);}
162
+ Some(previous)
163
+ } else if current.verified_matches(&self.options,&[],deadline)? {None} else {return Err(Error::Conflict);};
164
+ self.applied=known;self.pending=None;
165
+ }
166
+ if let Some(applied)=self.applied.clone() {self.release(applied,deadline)?;} else {
167
+ self.context()?;
168
+ if !frontend::Snapshot::read(deadline)?.verified_matches(&self.options,&[],deadline)? {return Err(Error::Conflict);}
169
+ self.released=true;self.error=None;
170
+ }
171
+ Ok(())
172
+ }
173
+ }
174
+ pub fn dispatch(owner: &mut Option<Owner>, request: Request) -> Result<serde_json::Value> {
175
+ let deadline=frontend::deadline();
176
+ match request.method.as_str() {
177
+ "prepareDockerForwarding"=>encode(decode::<policy::Policy>(request.params)?.prepare(frontend::version(deadline)?)?),
178
+ "openDockerForwarding"=>{if owner.is_some(){return Err(Error::Conflict);}*owner=Some(Owner::new(decode(request.params)?)?);encode(owner.as_mut().unwrap().inspect(deadline))},
179
+ "reconcileDockerForwarding"=>encode(owner.as_mut().ok_or(Error::Conflict)?.reconcile(decode(request.params)?,deadline)?),
180
+ "inspectDockerForwarding"|"closeDockerForwarding"=>{
181
+ if decode::<serde_json::Value>(request.params)?!=serde_json::json!({}){return Err(Error::Invalid);}
182
+ let owner=owner.as_mut().ok_or(Error::Conflict)?;
183
+ if request.method=="inspectDockerForwarding" {encode(owner.inspect(deadline))} else {
184
+ if let Err(error)=owner.close(deadline) {owner.error=Some(error);return Err(error);}
185
+ Ok(serde_json::json!({"released":true}))
186
+ }
187
+ },
188
+ "releaseDockerForwarding"=>{owner.as_mut().ok_or(Error::Conflict)?.release(decode(request.params)?,deadline)?;Ok(serde_json::json!({"released":true}))},
189
+ _=>Err(Error::Invalid),
190
+ }
191
+ }
@@ -0,0 +1,29 @@
1
+ use super::*;
2
+
3
+ #[test]
4
+ fn frontend_subprocess_drains_large_input_and_output_with_a_clean_environment() {
5
+ let input="bounded-input\n".repeat(12_000);
6
+ assert_eq!(command("/usr/bin/cat",&[],input.as_bytes(),deadline()).unwrap(),input);
7
+ let environment=command("/usr/bin/env",&[],&[],deadline()).unwrap();
8
+ let values:BTreeSet<_>=environment.lines().collect();
9
+ assert_eq!(values,BTreeSet::from(["PATH=/usr/sbin:/usr/bin:/sbin:/bin","LC_ALL=C"]));
10
+ }
11
+ fn child_failure(kind: &str, source: &str, expected: Error) {
12
+ let directory=std::env::temp_dir().join(format!("smartnftables-command-{}-{kind}",std::process::id()));
13
+ std::fs::create_dir(&directory).unwrap();
14
+ let pidfile=directory.join("pid");
15
+ let source=format!("import os,pathlib,time;pathlib.Path({:?}).write_text(str(os.getpid()));{source}",pidfile.to_str().unwrap());
16
+ assert_eq!(command("/usr/bin/python3",&["-c",&source],&[],deadline()),Err(expected));
17
+ let pid=std::fs::read_to_string(&pidfile).unwrap().parse::<i32>().unwrap();
18
+ assert_eq!(unsafe{libc::kill(pid,0)},-1);
19
+ assert_eq!(std::io::Error::last_os_error().raw_os_error(),Some(libc::ESRCH));
20
+ std::fs::remove_file(pidfile).unwrap();std::fs::remove_dir(directory).unwrap();
21
+ }
22
+ #[test]
23
+ fn frontend_subprocess_output_overflow_stops_and_joins_the_exact_child() {
24
+ child_failure("output","os.write(1,b'x'*2097153);time.sleep(60)",Error::Protocol);
25
+ }
26
+ #[test]
27
+ fn frontend_subprocess_timeout_stops_and_joins_the_exact_child() {
28
+ child_failure("timeout","time.sleep(60)",Error::Unavailable);
29
+ }
@@ -0,0 +1,148 @@
1
+ use super::*;
2
+ use serde_json::json;
3
+
4
+ fn options() -> Options {Options {owner_id:"dockerfixture".into(),instance_id:"qualification_20260913".into()}}
5
+ fn policy() -> policy::Policy {
6
+ let prepared:crate::managed::Prepared=serde_json::from_value::<crate::egress::Policy>(crate::egress::tests::host()).unwrap().prepare().unwrap().into();
7
+ policy::Policy {schema_version:1,revision:1,barrier:owner::Applied {
8
+ 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
+ fn text(rules: &[policy::Rule]) -> String {
11
+ let mut text="*filter\n:INPUT ACCEPT [0:0]\n:FORWARD DROP [0:0]\n:OUTPUT ACCEPT [0:0]\n:DOCKER-USER - [0:0]\n:DOCKER-FORWARD - [0:0]\n-A FORWARD -j DOCKER-USER\n-A FORWARD -j DOCKER-FORWARD\n".to_owned();
12
+ for rule in rules {text.push_str(&format!("-A DOCKER-USER {}\n",rule.args.join(" ")));}
13
+ text.push_str("-A DOCKER-USER -m comment --comment \"foreign unrelated rule\" -j RETURN\nCOMMIT\n");text
14
+ }
15
+ fn snapshot(text: &str) -> frontend::Snapshot {frontend::Snapshot::parse("v1.8.11 (nf_tables)".into(),text).unwrap()}
16
+
17
+ #[test]
18
+ fn docker_compiler_derives_only_scoped_directed_ranges_from_host_barrier() {
19
+ let prepared=policy().prepare("v1.8.11 (nf_tables)".into()).unwrap();let rules=prepared.rules(&options()).unwrap();
20
+ assert_eq!(rules.len(),6);
21
+ assert!(rules.iter().all(|r|r.args.contains(&"--ctorigsrcport".into()) && r.args.contains(&"--ctdir".into()) && !r.args.iter().any(|s|s.contains("RELATED"))));
22
+ let new_tcp=&rules[0].args;
23
+ assert!(new_tcp.windows(3).any(|w|w==["--tcp-flags","FIN,SYN,RST,ACK","SYN"]));
24
+ assert!(rules.iter().all(|r|r.comment.len()<256));
25
+ assert!(rules.iter().all(|r|r.args.windows(2).any(|w|w[0]=="--ctorigsrc" && !w[1].contains('/'))));
26
+ assert!(snapshot(&text(&rules)).matches(&options(),&rules).unwrap());
27
+ }
28
+ #[test]
29
+ fn docker_prepare_rejects_router_receipts_bad_hashes_and_unbounded_restore() {
30
+ let mut value=policy();value.schema_version=2;assert!(value.prepare("v1.8.11 (nf_tables)".into()).is_err());
31
+ let mut value=policy();value.barrier.receipt.digest="sha256:wrong".into();assert!(value.prepare("v1.8.11 (nf_tables)".into()).is_err());
32
+ let mut value=policy();value.barrier.prepared.policy=crate::managed::Policy::Egress(serde_json::from_value(crate::egress::tests::router()).unwrap());assert!(value.prepare("v1.8.11 (nf_tables)".into()).is_err());
33
+ let value=serde_json::to_value(policy()).unwrap();
34
+ let mut extra=value.clone();extra["rules"]=json!([]);assert!(serde_json::from_value::<policy::Policy>(extra).is_err());
35
+ let mut extra=value;extra["barrier"]["receipt"]["tableHandle"]=json!("01");assert!(serde_json::from_value::<policy::Policy>(extra).unwrap().prepare("v1.8.11 (nf_tables)".into()).is_err());
36
+ }
37
+ #[test]
38
+ fn docker_policy_digest_binds_barrier_handle_and_revision() {
39
+ let expected=policy().prepare("v1.8.11 (nf_tables)".into()).unwrap();
40
+ assert_ne!(policy().prepare("v1.8.10 (nf_tables)".into()).unwrap().digest,expected.digest);
41
+ for backend in ["v1.8. (nf_tables)","v1.8.9 (nf_tables)","v1.8.11 (legacy)","v1.8.011 (nf_tables)"] {
42
+ assert!(policy().prepare(backend.into()).is_err());
43
+ }
44
+ let mut value=policy();value.barrier.receipt.table_handle="2".into();assert_ne!(value.prepare("v1.8.11 (nf_tables)".into()).unwrap().digest,expected.digest);
45
+ let mut value=policy();value.revision=2;assert_ne!(value.prepare("v1.8.11 (nf_tables)".into()).unwrap().digest,expected.digest);
46
+ let mut forged=expected;forged.policy.revision=2;assert!(forged.validate().is_err());
47
+ }
48
+ #[test]
49
+ fn docker_snapshot_accepts_only_equivalent_frontend_rendering() {
50
+ let rules=policy().prepare("v1.8.11 (nf_tables)".into()).unwrap().rules(&options()).unwrap();
51
+ let original=text(&rules);
52
+ let rendered=original.replace("--ctorigsrc 10.240.0.2/32","--ctorigsrc 10.240.0.2");
53
+ assert!(snapshot(&rendered).matches(&options(),&rules).unwrap());
54
+ for (before,after) in [("--ctdir ORIGINAL","--ctdir REPLY"),("--ctstate NEW","--ctstate RELATED"),("--sport 10000:10999","--sport 1:65535"),("-o ens18","-o foreign"),("-j ACCEPT","-j RETURN"),("FIN,SYN,RST,ACK SYN","FIN,SYN,RST,ACK ACK")] {
55
+ assert!(!snapshot(&original.replacen(before,after,1)).matches(&options(),&rules).unwrap());
56
+ }
57
+ assert!(snapshot(&original.replacen("-j ACCEPT","-m mark --mark 1 -j ACCEPT",1)).matches(&options(),&rules).is_err());
58
+ }
59
+ #[test]
60
+ fn docker_snapshot_rejects_duplicates_moved_owner_and_foreign_instance() {
61
+ let rules=policy().prepare("v1.8.11 (nf_tables)".into()).unwrap().rules(&options()).unwrap();
62
+ let original=text(&rules);
63
+ let duplicate=original.replace("COMMIT",&format!("-A DOCKER-USER {}\nCOMMIT",rules[0].args.join(" ")));
64
+ assert!(!snapshot(&duplicate).matches(&options(),&rules).unwrap());
65
+ let moved=original.replacen("-A DOCKER-USER", "-A INPUT",1);
66
+ assert!(!snapshot(&moved).matches(&options(),&rules).unwrap());
67
+ assert!(!snapshot(&original.replace("qualification_20260913","foreign_instance_2026")).matches(&options(),&rules).unwrap());
68
+ assert_eq!(snapshot(&original.replace("snftd1:dockerfixture:","snftd1:anotherowner:")).matches(&options(),&[]),Err(Error::ForeignOwner));
69
+ let prepended=original.replacen("-A DOCKER-USER","-A DOCKER-USER -j RETURN\n-A DOCKER-USER",1);
70
+ assert!(!snapshot(&prepended).matches(&options(),&rules).unwrap());
71
+ assert!(!snapshot(&original).matches(&options(),&[]).unwrap());
72
+ }
73
+ #[test]
74
+ fn docker_snapshot_rejects_wrong_backend_path_policy_chain_order_and_table_framing() {
75
+ let original=text(&[]);
76
+ for changed in [original.replace(":FORWARD DROP",":FORWARD ACCEPT"),original.replace("-A FORWARD -j DOCKER-USER","-A FORWARD -j ACCEPT"),original.replace("*filter","*nat"),original.replace("COMMIT","COMMIT\n*filter"),original.replace(":DOCKER-USER - [0:0]\n","")] {
77
+ assert!(frontend::Snapshot::parse("v1.8.11 (nf_tables)".into(),&changed).is_err());
78
+ }
79
+ }
80
+
81
+ #[test]
82
+ fn docker_raw_graph_rejects_early_verdicts_reordered_matches_and_hidden_bytes() {
83
+ let rule=policy().prepare("v1.8.11 (nf_tables)".into()).unwrap().rules(&options()).unwrap().remove(0);
84
+ let expressions=graph::expected(&rule).unwrap();
85
+ let row=|expressions|vec![crate::wire::Attr::string(1,"filter"),crate::wire::Attr::string(2,"DOCKER-USER"),
86
+ crate::wire::Attr::u64(3,42),crate::wire::Attr::nested(4,expressions)];
87
+ assert!(graph::matches_rule(&row(expressions.clone()),&rule).unwrap());
88
+ let mut early=expressions.clone();let verdict=early.pop().unwrap();early.insert(0,verdict);
89
+ assert!(!graph::matches_rule(&row(early),&rule).unwrap());
90
+ let mut reordered=expressions.clone();reordered.swap(0,1);
91
+ assert!(!graph::matches_rule(&row(reordered),&rule).unwrap());
92
+ let mut moved_counter=expressions.clone();let length=moved_counter.len();moved_counter.swap(length-1,length-2);
93
+ assert!(!graph::matches_rule(&row(moved_counter),&rule).unwrap());
94
+ let mut hidden=expressions.clone();
95
+ for expression in &mut hidden {
96
+ let mut fields=crate::wire::attrs(&expression.value).unwrap();
97
+ if crate::wire::text(&fields,1).unwrap()!="match" {continue;}
98
+ let mut data=crate::wire::attrs(&crate::wire::one(&fields,2).unwrap().value).unwrap();
99
+ if crate::wire::text(&data,1).unwrap()!="conntrack" {continue;}
100
+ data.iter_mut().find(|a|a.id()==3).unwrap().value[20..32].fill(0);
101
+ *fields.iter_mut().find(|a|a.id()==2).unwrap()=crate::wire::Attr::nested(2,data);
102
+ expression.value=crate::wire::encode_attrs(&fields);
103
+ }
104
+ assert!(!graph::matches_rule(&row(hidden),&rule).unwrap());
105
+ }
106
+
107
+ #[test]
108
+ fn docker_raw_graph_accepts_kernel_field_order_flags_and_changing_counters_only() {
109
+ let rule=policy().prepare("v1.8.11 (nf_tables)".into()).unwrap().rules(&options()).unwrap().remove(0);
110
+ let mut expressions=graph::expected(&rule).unwrap();
111
+ for expression in &mut expressions {
112
+ let mut fields=crate::wire::attrs(&expression.value).unwrap();
113
+ if crate::wire::text(&fields,1).unwrap()=="counter" {
114
+ *fields.iter_mut().find(|a|a.id()==2).unwrap()=crate::wire::Attr::nested(2,
115
+ vec![crate::wire::Attr::u64(2,123),crate::wire::Attr::u64(1,456)]);
116
+ }
117
+ fields.reverse();expression.value=crate::wire::encode_attrs(&fields);
118
+ }
119
+ fn kernel(attributes: &mut [crate::wire::Attr]) {
120
+ for attribute in attributes {
121
+ if attribute.kind&0x8000!=0 {
122
+ let mut children=crate::wire::attrs(&attribute.value).unwrap();kernel(&mut children);
123
+ attribute.value=crate::wire::encode_attrs(&children);
124
+ }
125
+ attribute.kind &= 0x3fff;
126
+ }
127
+ }
128
+ kernel(&mut expressions);
129
+ let row=vec![crate::wire::Attr::string(1,"filter"),crate::wire::Attr::string(2,"DOCKER-USER"),
130
+ crate::wire::Attr::u64(3,42),crate::wire::Attr::nested(4,expressions)];
131
+ assert!(graph::matches_rule(&row,&rule).unwrap());
132
+ }
133
+
134
+ #[test]
135
+ fn docker_forward_jumps_require_exact_unconditional_order() {
136
+ use crate::{policy::{expr,verdict},wire::Attr};
137
+ let row=|expressions|vec![Attr::string(1,"filter"),Attr::string(2,"FORWARD"),
138
+ Attr::u64(3,42),Attr::nested(4,expressions)];
139
+ for target in ["DOCKER-USER","DOCKER-FORWARD"] {
140
+ let expected=vec![expr("counter",vec![Attr::u64(1,7),Attr::u64(2,11)]),verdict(-3,Some(target))];
141
+ assert!(graph::matches_forward(&row(expected.clone()),target).unwrap());
142
+ let mut early=expected.clone();early.insert(0,verdict(1,None));
143
+ assert!(!graph::matches_forward(&row(early),target).unwrap());
144
+ let mut reordered=expected.clone();reordered.reverse();
145
+ assert!(!graph::matches_forward(&row(reordered),target).unwrap());
146
+ assert!(!graph::matches_forward(&row(expected),if target=="DOCKER-USER" {"DOCKER-FORWARD"} else {"DOCKER-USER"}).unwrap());
147
+ }
148
+ }