@push.rocks/smartnftables 1.5.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.
- package/changelog.md +12 -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 +17 -0
- package/dist_ts/classes.manageddockerforwarding.js +128 -0
- package/dist_ts/classes.managednftables.d.ts +2 -0
- package/dist_ts/classes.managednftables.js +2 -2
- package/dist_ts/index.d.ts +2 -0
- package/dist_ts/index.js +3 -1
- package/dist_ts/managed.docker.types.d.ts +78 -0
- package/dist_ts/managed.docker.types.js +2 -0
- package/package.json +1 -1
- package/readme.md +71 -5
- package/rust/src/docker.frontend.rs +203 -0
- package/rust/src/docker.graph.rs +129 -0
- package/rust/src/docker.policy.rs +140 -0
- package/rust/src/docker.rs +191 -0
- package/rust/src/docker_process_tests.rs +29 -0
- package/rust/src/docker_tests.rs +148 -0
- package/rust/src/main.rs +10 -5
- package/rust/src/owner.rs +19 -1
- package/rust/src/wire.links.rs +11 -0
- package/rust/src/wire.rs +14 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.manageddockerforwarding.ts +113 -0
- package/ts/classes.managednftables.ts +1 -1
- package/ts/index.ts +2 -0
- package/ts/managed.docker.types.ts +49 -0
|
@@ -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
|
+
}
|
package/rust/src/main.rs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
mod egress;
|
|
2
|
+
mod docker;
|
|
2
3
|
mod managed;
|
|
3
4
|
mod namespace;
|
|
4
5
|
mod owner;
|
|
@@ -14,6 +15,7 @@ use std::io::{BufRead, Read, Write};
|
|
|
14
15
|
pub enum Error {
|
|
15
16
|
Invalid,
|
|
16
17
|
Unsupported,
|
|
18
|
+
UnsupportedBackend,
|
|
17
19
|
Permission,
|
|
18
20
|
ForeignOwner,
|
|
19
21
|
Conflict,
|
|
@@ -27,6 +29,7 @@ impl Error {
|
|
|
27
29
|
match self {
|
|
28
30
|
Self::Invalid => "INVALID",
|
|
29
31
|
Self::Unsupported => "UNSUPPORTED_KERNEL",
|
|
32
|
+
Self::UnsupportedBackend => "UNSUPPORTED_BACKEND",
|
|
30
33
|
Self::Permission => "PERMISSION",
|
|
31
34
|
Self::ForeignOwner => "FOREIGN_OWNER",
|
|
32
35
|
Self::Conflict => "CONFLICT",
|
|
@@ -101,13 +104,14 @@ fn dispatch(owner: &mut Option<owner::Owner>, request: Request) -> Result<serde_
|
|
|
101
104
|
|
|
102
105
|
fn main() {
|
|
103
106
|
let arguments = std::env::args().skip(1).collect::<Vec<_>>();
|
|
104
|
-
let descriptor = match arguments.as_slice() {
|
|
105
|
-
[management] if management == "--management" => None,
|
|
107
|
+
let (docker_mode, descriptor) = match arguments.as_slice() {
|
|
108
|
+
[management] if management == "--management" => (false, None),
|
|
109
|
+
[management] if management == "--docker-forwarding" => (true, None),
|
|
106
110
|
[management, option, fd]
|
|
107
|
-
if management == "--management" && option == "--network-namespace-fd" =>
|
|
111
|
+
if (management == "--management" || management == "--docker-forwarding") && option == "--network-namespace-fd" =>
|
|
108
112
|
{
|
|
109
113
|
match fd.parse::<i32>() {
|
|
110
|
-
Ok(value) => Some(value),
|
|
114
|
+
Ok(value) => (management == "--docker-forwarding", Some(value)),
|
|
111
115
|
Err(_) => std::process::exit(2),
|
|
112
116
|
}
|
|
113
117
|
}
|
|
@@ -125,6 +129,7 @@ fn main() {
|
|
|
125
129
|
output.flush().unwrap();
|
|
126
130
|
let mut input = std::io::stdin().lock();
|
|
127
131
|
let mut owner = None;
|
|
132
|
+
let mut docker = None;
|
|
128
133
|
loop {
|
|
129
134
|
let mut bytes = Vec::new();
|
|
130
135
|
let read = std::io::Read::by_ref(&mut input)
|
|
@@ -144,7 +149,7 @@ fn main() {
|
|
|
144
149
|
std::process::exit(2);
|
|
145
150
|
}
|
|
146
151
|
let id = request.id.clone();
|
|
147
|
-
let response = match dispatch(&mut owner, request) {
|
|
152
|
+
let response = match if docker_mode { docker::dispatch(&mut docker, request) } else { dispatch(&mut owner, request) } {
|
|
148
153
|
Ok(result) => serde_json::json!({"id":id,"success":true,"result":result}),
|
|
149
154
|
Err(error) => {
|
|
150
155
|
serde_json::json!({"id":id,"success":false,"error":"Managed policy operation failed.","errorCode":error.code()})
|
package/rust/src/owner.rs
CHANGED
|
@@ -70,7 +70,7 @@ pub struct Owner {
|
|
|
70
70
|
released: bool,
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
fn context() -> Result<(String, String, String)> {
|
|
73
|
+
pub(crate) fn context() -> Result<(String, String, String)> {
|
|
74
74
|
let boot = std::fs::read_to_string("/proc/sys/kernel/random/boot_id")
|
|
75
75
|
.map_err(|_| Error::Unavailable)?
|
|
76
76
|
.trim()
|
|
@@ -112,6 +112,24 @@ fn kernel_floor() -> Result<()> {
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
impl Owner {
|
|
115
|
+
/// Read another dedicated owner's exact receipt without claiming its table.
|
|
116
|
+
/// PERSIST may retain enforcement after that owner's socket closes.
|
|
117
|
+
pub(crate) fn verify_external(expected: &Applied) -> Result<()> {
|
|
118
|
+
let identity = &expected.receipt.identity;
|
|
119
|
+
let mut reader = Self::new(Options {
|
|
120
|
+
owner_id: identity.owner_id.clone(),
|
|
121
|
+
instance_id: identity.instance_id.clone(),
|
|
122
|
+
table_name: identity.table_name.clone(),
|
|
123
|
+
})?;
|
|
124
|
+
reader.validate_receipt(expected)?;
|
|
125
|
+
let graph = reader.graph()?.ok_or(Error::Conflict)?;
|
|
126
|
+
reader.binding(&graph, Some(expected))?;
|
|
127
|
+
if !reader.matches(&graph, &expected.prepared)? {
|
|
128
|
+
return Err(Error::Conflict);
|
|
129
|
+
}
|
|
130
|
+
expected.prepared.validate_interfaces()
|
|
131
|
+
}
|
|
132
|
+
|
|
115
133
|
pub fn new(options: Options) -> Result<Self> {
|
|
116
134
|
if !identifier(&options.owner_id)
|
|
117
135
|
|| !identifier(&options.instance_id)
|
package/rust/src/wire.links.rs
CHANGED
|
@@ -266,3 +266,14 @@ pub fn verify_bound_interfaces<'a>(
|
|
|
266
266
|
}
|
|
267
267
|
Ok(())
|
|
268
268
|
}
|
|
269
|
+
|
|
270
|
+
/// Mutation fence: the caller retains these exact links until cleanup completes.
|
|
271
|
+
pub fn verify_bound_interfaces_down<'a>(interfaces: impl Iterator<Item = BoundInterface<'a>>) -> Result<()> {
|
|
272
|
+
let mut socket = Socket::open_protocol(libc::NETLINK_ROUTE)?;
|
|
273
|
+
for binding in interfaces {
|
|
274
|
+
let link = read_link(&mut socket, binding.index)?;
|
|
275
|
+
matches(&link, &binding)?;
|
|
276
|
+
if link.flags & libc::IFF_UP as u32 != 0 {return Err(Error::Conflict);}
|
|
277
|
+
}
|
|
278
|
+
Ok(())
|
|
279
|
+
}
|
package/rust/src/wire.rs
CHANGED
|
@@ -125,7 +125,7 @@ pub struct Socket {
|
|
|
125
125
|
|
|
126
126
|
#[path = "wire.links.rs"]
|
|
127
127
|
mod links;
|
|
128
|
-
pub use links::{verify_bound_interfaces, verify_interfaces, BoundInterface};
|
|
128
|
+
pub use links::{verify_bound_interfaces, verify_bound_interfaces_down, verify_interfaces, BoundInterface};
|
|
129
129
|
impl Socket {
|
|
130
130
|
pub fn open() -> Result<Self> {
|
|
131
131
|
Self::open_protocol(libc::NETLINK_NETFILTER)
|
|
@@ -192,6 +192,9 @@ impl Socket {
|
|
|
192
192
|
Ok(self.sequence)
|
|
193
193
|
}
|
|
194
194
|
fn message(&mut self, kind: u16, flags: u16, attributes: &[Attr]) -> Result<(u32, Vec<u8>)> {
|
|
195
|
+
self.message_family(kind, flags, attributes, 1)
|
|
196
|
+
}
|
|
197
|
+
fn message_family(&mut self, kind: u16, flags: u16, attributes: &[Attr], family: u8) -> Result<(u32, Vec<u8>)> {
|
|
195
198
|
let sequence = self.sequence()?;
|
|
196
199
|
let batch = kind == 16 || kind == 17;
|
|
197
200
|
let payload = encode_attrs(attributes);
|
|
@@ -204,7 +207,7 @@ impl Socket {
|
|
|
204
207
|
bytes.extend(flags.to_ne_bytes());
|
|
205
208
|
bytes.extend(sequence.to_ne_bytes());
|
|
206
209
|
bytes.extend(self.port.to_ne_bytes());
|
|
207
|
-
bytes.extend([if batch { 0 } else {
|
|
210
|
+
bytes.extend([if batch { 0 } else { family }, 0]);
|
|
208
211
|
bytes.extend(if batch { 10_u16 } else { 0 }.to_be_bytes());
|
|
209
212
|
bytes.extend(payload);
|
|
210
213
|
Ok((sequence, bytes))
|
|
@@ -287,8 +290,15 @@ impl Socket {
|
|
|
287
290
|
input: Vec<Attr>,
|
|
288
291
|
dump: bool,
|
|
289
292
|
) -> Result<Vec<Vec<Attr>>> {
|
|
293
|
+
self.query_family(operation, input, dump, 1)
|
|
294
|
+
}
|
|
295
|
+
/// Read Docker's IPv4 nft graph without claiming or mutating its table.
|
|
296
|
+
pub fn query_ipv4(&mut self, operation: u16, input: Vec<Attr>, dump: bool) -> Result<Vec<Vec<Attr>>> {
|
|
297
|
+
self.query_family(operation, input, dump, 2)
|
|
298
|
+
}
|
|
299
|
+
fn query_family(&mut self, operation: u16, input: Vec<Attr>, dump: bool, family: u8) -> Result<Vec<Vec<Attr>>> {
|
|
290
300
|
let (sequence, bytes) =
|
|
291
|
-
self.
|
|
301
|
+
self.message_family(0x0a00 | operation, 1 | if dump { 0x300 } else { 0 }, &input, family)?;
|
|
292
302
|
self.send(&bytes)?;
|
|
293
303
|
let until = Instant::now() + Duration::from_secs(3);
|
|
294
304
|
let mut result = Vec::new();
|
|
@@ -325,6 +335,7 @@ impl Socket {
|
|
|
325
335
|
if total > 2_097_152 || result.len() >= 4096 || message.payload.len() < 4 {
|
|
326
336
|
return Err(Error::Protocol);
|
|
327
337
|
}
|
|
338
|
+
if family == 2 && message.payload[0] != family {return Err(Error::Protocol);}
|
|
328
339
|
result.push(attrs(&message.payload[4..])?);
|
|
329
340
|
if !dump {
|
|
330
341
|
return Ok(result);
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import { snapshot, ManagedNftablesError } from './classes.managednftables.js';
|
|
3
|
+
import type { IManagedDockerForwardingOptions, IManagedDockerForwardingStatus, IManagedDockerForwardingPolicy, IPreparedManagedDockerForwardingPolicy, IManagedDockerForwardingTransition, IAppliedManagedDockerForwardingPolicy, TManagedDockerForwardingCommands } from './managed.docker.types.js';
|
|
4
|
+
|
|
5
|
+
/** One node-level Docker DOCKER-USER contribution. Caller owns durable intents and handoff lifetimes. */
|
|
6
|
+
export class ManagedDockerForwarding {
|
|
7
|
+
readonly #options: Readonly<IManagedDockerForwardingOptions>;
|
|
8
|
+
#bridge: plugins.smartrust.RustBridge<TManagedDockerForwardingCommands> | null = null;
|
|
9
|
+
#state: 'new' | 'starting' | 'ready' | 'closing' | 'failed-owned' | 'stopped' = 'new';
|
|
10
|
+
#work: Promise<unknown> | null = null;
|
|
11
|
+
#close: Promise<void> | null = null;
|
|
12
|
+
#nativeMayOwn = false;
|
|
13
|
+
|
|
14
|
+
constructor(options: IManagedDockerForwardingOptions) {
|
|
15
|
+
this.#options = snapshot(options);
|
|
16
|
+
if (Object.keys(this.#options).some((key) => !['ownerId', 'instanceId', 'binaryPath', 'networkNamespaceFd'].includes(key))
|
|
17
|
+
|| ![this.#options.ownerId, this.#options.instanceId].every((value) => typeof value === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(value))
|
|
18
|
+
|| this.#options.instanceId.length < 16
|
|
19
|
+
|| this.#options.binaryPath !== undefined && (!plugins.path.isAbsolute(this.#options.binaryPath) || this.#options.binaryPath.includes('\0'))) {
|
|
20
|
+
throw new ManagedNftablesError('INVALID');
|
|
21
|
+
}
|
|
22
|
+
if (this.#options.networkNamespaceFd !== undefined && (process.platform !== 'linux'
|
|
23
|
+
|| !Number.isInteger(this.#options.networkNamespaceFd) || this.#options.networkNamespaceFd < 0
|
|
24
|
+
|| this.#options.networkNamespaceFd > 0x7fff_ffff)) throw new ManagedNftablesError('INVALID');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
public get state(): string { return this.#state; }
|
|
28
|
+
|
|
29
|
+
/** Starts only the compiler/IPC owner. The returned idle status is not enforcement. */
|
|
30
|
+
public start(): Promise<IManagedDockerForwardingStatus> {
|
|
31
|
+
if (this.#state !== 'new') return Promise.reject(new ManagedNftablesError('UNAVAILABLE'));
|
|
32
|
+
this.#state = 'starting';
|
|
33
|
+
const work = (async () => {
|
|
34
|
+
if (process.platform !== 'linux' || !['x64', 'arm64'].includes(process.arch)) throw new ManagedNftablesError('UNSUPPORTED_PLATFORM');
|
|
35
|
+
const root = plugins.url.fileURLToPath(new URL('../', import.meta.url));
|
|
36
|
+
this.#bridge = new plugins.smartrust.RustBridge<TManagedDockerForwardingCommands>({
|
|
37
|
+
binaryName: 'smartnftables', binaryPath: this.#options.binaryPath
|
|
38
|
+
?? plugins.path.join(root, 'dist_rust', `smartnftables_linux_${process.arch === 'x64' ? 'amd64' : 'arm64'}_musl`),
|
|
39
|
+
localPaths: [], searchSystemPath: false,
|
|
40
|
+
cliArgs: ['--docker-forwarding', ...(this.#options.networkNamespaceFd === undefined ? [] : ['--network-namespace-fd', '3'])],
|
|
41
|
+
inheritedFileDescriptors: this.#options.networkNamespaceFd === undefined ? undefined : [this.#options.networkNamespaceFd],
|
|
42
|
+
readyTimeoutMs: 3000, requestTimeoutMs: 30_000, maxPayloadSize: 262_144,
|
|
43
|
+
maxPendingRequestsByMethod: { prepareDockerForwarding: 1, openDockerForwarding: 1, reconcileDockerForwarding: 1, inspectDockerForwarding: 1, releaseDockerForwarding: 1, closeDockerForwarding: 1 },
|
|
44
|
+
});
|
|
45
|
+
this.#bridge.on('exit', () => {
|
|
46
|
+
if (!['closing', 'stopped'].includes(this.#state)) this.#state = 'failed-owned';
|
|
47
|
+
});
|
|
48
|
+
if (!await this.#bridge.spawn()) throw new ManagedNftablesError('UNAVAILABLE');
|
|
49
|
+
const { ownerId, instanceId } = this.#options;
|
|
50
|
+
const status = snapshot(await this.#bridge.sendCommand('openDockerForwarding', { ownerId, instanceId }));
|
|
51
|
+
if (this.#state !== 'starting') throw new ManagedNftablesError('UNAVAILABLE');
|
|
52
|
+
this.#state = 'ready'; return status;
|
|
53
|
+
})();
|
|
54
|
+
return this.#track(work);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
public prepare(policy: IManagedDockerForwardingPolicy): Promise<IPreparedManagedDockerForwardingPolicy> {
|
|
58
|
+
return this.#invoke('prepareDockerForwarding', policy);
|
|
59
|
+
}
|
|
60
|
+
public reconcile(transition: IManagedDockerForwardingTransition): Promise<IAppliedManagedDockerForwardingPolicy> {
|
|
61
|
+
return this.#invoke('reconcileDockerForwarding', transition);
|
|
62
|
+
}
|
|
63
|
+
public inspect(): Promise<IManagedDockerForwardingStatus> { return this.#invoke('inspectDockerForwarding', {}); }
|
|
64
|
+
public release(expected: IAppliedManagedDockerForwardingPolicy): Promise<{ released: true }> {
|
|
65
|
+
return this.#invoke('releaseDockerForwarding', expected);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Join operations and exact contributed-rule deletion, then native termination. Keep affected handoff links down through cleanup. */
|
|
69
|
+
public close(): Promise<void> {
|
|
70
|
+
if (this.#close) return this.#close;
|
|
71
|
+
if (this.#state === 'stopped') return Promise.resolve();
|
|
72
|
+
this.#state = 'closing';
|
|
73
|
+
this.#close = (async () => {
|
|
74
|
+
await this.#work?.catch(() => undefined);
|
|
75
|
+
if (this.#bridge) {
|
|
76
|
+
if (this.#nativeMayOwn) {
|
|
77
|
+
const result = await this.#bridge.sendCommand('closeDockerForwarding', {});
|
|
78
|
+
if (result.released !== true) throw new ManagedNftablesError('PROTOCOL');
|
|
79
|
+
}
|
|
80
|
+
await this.#bridge.terminate({ gracePeriodMs: 1000 });
|
|
81
|
+
}
|
|
82
|
+
this.#bridge = null; this.#state = 'stopped';
|
|
83
|
+
})().catch(() => {
|
|
84
|
+
this.#state = 'failed-owned'; this.#close = null;
|
|
85
|
+
throw new ManagedNftablesError('CLEANUP_UNCONFIRMED');
|
|
86
|
+
});
|
|
87
|
+
return this.#close;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#invoke<K extends keyof TManagedDockerForwardingCommands>(method: K, params: TManagedDockerForwardingCommands[K]['params']): Promise<TManagedDockerForwardingCommands[K]['result']> {
|
|
91
|
+
if (!['ready', 'failed-owned'].includes(this.#state) || !this.#bridge || this.#work) return Promise.reject(new ManagedNftablesError('UNAVAILABLE'));
|
|
92
|
+
let captured: TManagedDockerForwardingCommands[K]['params'];
|
|
93
|
+
try { captured = snapshot(params); } catch { return Promise.reject(new ManagedNftablesError('INVALID')); }
|
|
94
|
+
if (method === 'reconcileDockerForwarding' || method === 'releaseDockerForwarding') this.#nativeMayOwn = true;
|
|
95
|
+
return this.#track(this.#bridge.sendCommand(method, captured).then((result) => {
|
|
96
|
+
const capturedResult = snapshot(result);
|
|
97
|
+
if (method === 'releaseDockerForwarding') this.#nativeMayOwn = false;
|
|
98
|
+
if (this.#state !== 'closing' && (method === 'reconcileDockerForwarding' || method === 'releaseDockerForwarding'
|
|
99
|
+
|| method === 'inspectDockerForwarding' && (capturedResult as IManagedDockerForwardingStatus).error === null)) this.#state = 'ready';
|
|
100
|
+
return capturedResult;
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
#track<T>(work: Promise<T>): Promise<T> {
|
|
104
|
+
const owned = work.catch((error) => {
|
|
105
|
+
if (this.#state !== 'closing') this.#state = 'failed-owned';
|
|
106
|
+
const code = error instanceof plugins.smartrust.RustBridgeRequestError ? error.responseErrorCode : undefined;
|
|
107
|
+
throw new ManagedNftablesError(typeof code === 'string' && /^[A-Z_]{1,64}$/.test(code) ? code : 'UNAVAILABLE');
|
|
108
|
+
});
|
|
109
|
+
this.#work = owned;
|
|
110
|
+
void owned.then(() => { this.#work = null; }, () => { this.#work = null; });
|
|
111
|
+
return owned;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -7,7 +7,7 @@ export class ManagedNftablesError extends Error {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
/** Capture inert input without evaluating getters, proxies, coercions or toJSON. */
|
|
10
|
-
function snapshot<T>(input: T): T {
|
|
10
|
+
export function snapshot<T>(input: T): T {
|
|
11
11
|
let count = 0;
|
|
12
12
|
let stringBytes = 0;
|
|
13
13
|
const string = (value: string): string => {
|
package/ts/index.ts
CHANGED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { IAppliedManagedNftPolicy, IManagedNftOptions } from './managed.types.js';
|
|
2
|
+
import type { IManagedNftPolicyV2, IManagedNftHostTransitScopeV2 } from './managed.egress.types.js';
|
|
3
|
+
|
|
4
|
+
export interface IManagedDockerForwardingOptions extends Omit<IManagedNftOptions, 'tableName'> {}
|
|
5
|
+
export interface IManagedDockerForwardingPolicy {
|
|
6
|
+
schemaVersion: 1;
|
|
7
|
+
revision: number;
|
|
8
|
+
/** Exact dedicated host barrier. This contribution never adopts or modifies its table. */
|
|
9
|
+
barrier: IAppliedManagedNftPolicy<IManagedNftPolicyV2 & { scope: IManagedNftHostTransitScopeV2 }>;
|
|
10
|
+
}
|
|
11
|
+
export interface IPreparedManagedDockerForwardingPolicy {
|
|
12
|
+
policy: IManagedDockerForwardingPolicy;
|
|
13
|
+
/** Exact nft frontend version captured during prepare and checked through recovery. */
|
|
14
|
+
backend: string;
|
|
15
|
+
digest: string;
|
|
16
|
+
}
|
|
17
|
+
export interface IManagedDockerForwardingIdentity {
|
|
18
|
+
ownerId: string;
|
|
19
|
+
instanceId: string;
|
|
20
|
+
bootId: string;
|
|
21
|
+
namespaceDevice: string;
|
|
22
|
+
namespaceInode: string;
|
|
23
|
+
}
|
|
24
|
+
export interface IAppliedManagedDockerForwardingPolicy {
|
|
25
|
+
receipt: { identity: IManagedDockerForwardingIdentity; backend: string; revision: number; digest: string };
|
|
26
|
+
prepared: IPreparedManagedDockerForwardingPolicy;
|
|
27
|
+
}
|
|
28
|
+
/** Persist before mutation. Keep both generations' exact handoff links down until reconciliation is confirmed. */
|
|
29
|
+
export interface IManagedDockerForwardingTransition {
|
|
30
|
+
previous: IAppliedManagedDockerForwardingPolicy | null;
|
|
31
|
+
target: IPreparedManagedDockerForwardingPolicy;
|
|
32
|
+
}
|
|
33
|
+
export interface IManagedDockerForwardingStatus {
|
|
34
|
+
identity: IManagedDockerForwardingIdentity;
|
|
35
|
+
state: 'idle' | 'applied' | 'failed-owned' | 'released';
|
|
36
|
+
/** Exact contribution and barrier inspected; does not attest the whole host path or durable fencing. */
|
|
37
|
+
present: boolean;
|
|
38
|
+
applied: IAppliedManagedDockerForwardingPolicy | null;
|
|
39
|
+
pending: IManagedDockerForwardingTransition | null;
|
|
40
|
+
error: string | null;
|
|
41
|
+
}
|
|
42
|
+
export type TManagedDockerForwardingCommands = {
|
|
43
|
+
prepareDockerForwarding: { params: IManagedDockerForwardingPolicy; result: IPreparedManagedDockerForwardingPolicy };
|
|
44
|
+
openDockerForwarding: { params: Pick<IManagedDockerForwardingOptions, 'ownerId' | 'instanceId'>; result: IManagedDockerForwardingStatus };
|
|
45
|
+
reconcileDockerForwarding: { params: IManagedDockerForwardingTransition; result: IAppliedManagedDockerForwardingPolicy };
|
|
46
|
+
inspectDockerForwarding: { params: Record<string, never>; result: IManagedDockerForwardingStatus };
|
|
47
|
+
releaseDockerForwarding: { params: IAppliedManagedDockerForwardingPolicy; result: { released: true } };
|
|
48
|
+
closeDockerForwarding: { params: Record<string, never>; result: { released: true } };
|
|
49
|
+
};
|