mxc 0.2.0 → 0.3.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.
- checksums.yaml +4 -4
- data/Cargo.lock +34 -2
- data/NOTICE +2 -2
- data/README.md +6 -3
- data/ext/mxc/Cargo.toml +4 -4
- data/ext/mxc/src/lib.rs +120 -34
- data/lib/mxc/availability.rb +9 -3
- data/lib/mxc/configuration.rb +2 -1
- data/lib/mxc/native.rb +8 -1
- data/lib/mxc/preflight.rb +15 -4
- data/lib/mxc/sandbox.rb +19 -10
- data/lib/mxc/startup_failure.rb +13 -8
- data/lib/mxc/version.rb +1 -1
- data/lib/mxc.rb +8 -3
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 988895e92e027354db083acc4b7fefd8b48d6ac8a5e4bfed22a734f4c020dd3d
|
|
4
|
+
data.tar.gz: 4d014ede1e4b3c35551304d1b92fdf49889587b009bb0fb0df39c96fb8718926
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f9330fd9f89ad26e9ce2494969b6734afb4fdb3f6e4a8f3712421e1d5f28d0e4efbc40965520a6ed3c0211b7ccf2c915fd74b05daed107ef02168e9b4b5f18f1
|
|
7
|
+
data.tar.gz: 44a17cc95d03e7a24b2fe2cc71fb32137c2983f01c155dbd2a9e1fb0051bd9193e3c22754acdbe050881ec0a3f64f0fcb4b2e131d09c13e4ea02038fc2fa4ed0
|
data/Cargo.lock
CHANGED
|
@@ -200,6 +200,26 @@ version = "1.19.0"
|
|
|
200
200
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
201
201
|
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
|
202
202
|
|
|
203
|
+
[[package]]
|
|
204
|
+
name = "enumflags2"
|
|
205
|
+
version = "0.7.12"
|
|
206
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
207
|
+
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
|
|
208
|
+
dependencies = [
|
|
209
|
+
"enumflags2_derive",
|
|
210
|
+
]
|
|
211
|
+
|
|
212
|
+
[[package]]
|
|
213
|
+
name = "enumflags2_derive"
|
|
214
|
+
version = "0.7.12"
|
|
215
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
216
|
+
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
|
|
217
|
+
dependencies = [
|
|
218
|
+
"proc-macro2",
|
|
219
|
+
"quote",
|
|
220
|
+
"syn 2.0.119",
|
|
221
|
+
]
|
|
222
|
+
|
|
203
223
|
[[package]]
|
|
204
224
|
name = "errno"
|
|
205
225
|
version = "0.3.14"
|
|
@@ -456,6 +476,17 @@ dependencies = [
|
|
|
456
476
|
"wasm-bindgen",
|
|
457
477
|
]
|
|
458
478
|
|
|
479
|
+
[[package]]
|
|
480
|
+
name = "landlock"
|
|
481
|
+
version = "0.4.7"
|
|
482
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
483
|
+
checksum = "4cca98e95f35b29d469dade6724c6f96cec9236640f745a0e99b0334ec320ab1"
|
|
484
|
+
dependencies = [
|
|
485
|
+
"enumflags2",
|
|
486
|
+
"libc",
|
|
487
|
+
"thiserror",
|
|
488
|
+
]
|
|
489
|
+
|
|
459
490
|
[[package]]
|
|
460
491
|
name = "lazy_static"
|
|
461
492
|
version = "1.5.1"
|
|
@@ -562,7 +593,7 @@ dependencies = [
|
|
|
562
593
|
|
|
563
594
|
[[package]]
|
|
564
595
|
name = "mxc"
|
|
565
|
-
version = "0.
|
|
596
|
+
version = "0.3.0"
|
|
566
597
|
dependencies = [
|
|
567
598
|
"magnus",
|
|
568
599
|
"mxc-sdk",
|
|
@@ -574,7 +605,7 @@ dependencies = [
|
|
|
574
605
|
[[package]]
|
|
575
606
|
name = "mxc-sdk"
|
|
576
607
|
version = "1.0.0"
|
|
577
|
-
source = "git+https://github.com/crmne/microsoft-mxc?rev=
|
|
608
|
+
source = "git+https://github.com/crmne/microsoft-mxc?rev=d57b0d1aa3a2d552001fa54ef4ac4baaa733c26d#d57b0d1aa3a2d552001fa54ef4ac4baaa733c26d"
|
|
578
609
|
dependencies = [
|
|
579
610
|
"anyhow",
|
|
580
611
|
"base64",
|
|
@@ -583,6 +614,7 @@ dependencies = [
|
|
|
583
614
|
"cidr",
|
|
584
615
|
"flatbuffers",
|
|
585
616
|
"getrandom 0.2.17",
|
|
617
|
+
"landlock",
|
|
586
618
|
"libc",
|
|
587
619
|
"nix",
|
|
588
620
|
"same-file",
|
data/NOTICE
CHANGED
|
@@ -6,8 +6,8 @@ endorsed by Microsoft.
|
|
|
6
6
|
|
|
7
7
|
The native extension compiles and links the mxc-sdk crate
|
|
8
8
|
(https://github.com/microsoft/mxc, https://crates.io/crates/mxc-sdk), from
|
|
9
|
-
a fork at https://github.com/crmne/microsoft-mxc that adds resource limits
|
|
10
|
-
which is distributed under the MIT License:
|
|
9
|
+
a fork at https://github.com/crmne/microsoft-mxc that adds resource limits
|
|
10
|
+
and a Landlock backend, which is distributed under the MIT License:
|
|
11
11
|
|
|
12
12
|
MIT License
|
|
13
13
|
|
data/README.md
CHANGED
|
@@ -22,7 +22,7 @@ result.stdout
|
|
|
22
22
|
result.success?
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
It is an unofficial Ruby binding to [Microsoft's MXC](https://github.com/microsoft/mxc), the MIT-licensed sandbox library. Not affiliated with or endorsed by Microsoft. Resource limits come from [a fork of MXC](https://github.com/crmne/microsoft-mxc) until they are
|
|
25
|
+
It is an unofficial Ruby binding to [Microsoft's MXC](https://github.com/microsoft/mxc), the MIT-licensed sandbox library. Not affiliated with or endorsed by Microsoft. Resource limits and the Landlock backend come from [a fork of MXC](https://github.com/crmne/microsoft-mxc) until they are part of MXC itself ([resource limits](https://github.com/microsoft/mxc/issues/1498), [Landlock](https://github.com/microsoft/mxc/issues/1494)).
|
|
26
26
|
|
|
27
27
|
## Installation
|
|
28
28
|
|
|
@@ -36,6 +36,8 @@ Precompiled gems cover Linux (x86_64 and ARM64, glibc and musl), macOS (Intel an
|
|
|
36
36
|
sudo apt-get install bubblewrap
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
+
Where Bubblewrap cannot create its namespaces, as in a default Docker container or on Ubuntu with AppArmor's restriction, sandboxes run on Landlock instead (Linux 6.2 or newer), without network access.
|
|
40
|
+
|
|
39
41
|
Commands that use the network also need `slirp4netns`, `iptables`, util-linux, and `/dev/net/tun`. macOS and Windows need nothing extra.
|
|
40
42
|
|
|
41
43
|
## Usage
|
|
@@ -117,15 +119,16 @@ MXC.backends # => [#<MXC::Backend name=:bubblewrap ...>]
|
|
|
117
119
|
|
|
118
120
|
## Platforms
|
|
119
121
|
|
|
120
|
-
| Platform | Backend | 0.
|
|
122
|
+
| Platform | Backend | 0.3.0 |
|
|
121
123
|
| --- | --- | --- |
|
|
122
124
|
| Linux | Bubblewrap | Supported and tested, without process limits |
|
|
125
|
+
| Linux 6.2 and later | Landlock, where Bubblewrap cannot run or when asked for | Supported and tested, without network access or process limits |
|
|
123
126
|
| macOS | Seatbelt | Supported and tested, without memory or process limits |
|
|
124
127
|
| Windows 11 24H2 and later | ProcessContainer | Supported and tested, without host allow-lists |
|
|
125
128
|
|
|
126
129
|
On Windows, a single String runs through `cmd.exe` instead of `sh`. See [Running Commands](https://crmne.github.io/mxc/running-commands/#windows) and [Policies](https://crmne.github.io/mxc/policies/#windows) for what else differs.
|
|
127
130
|
|
|
128
|
-
Docker blocks the namespaces Bubblewrap needs by default. [Running in Docker](https://crmne.github.io/mxc/running-in-docker/) shows the smallest set of changes that makes
|
|
131
|
+
Docker blocks the namespaces Bubblewrap needs by default, so a default container runs sandboxes on Landlock. [Running in Docker](https://crmne.github.io/mxc/running-in-docker/) shows what that gives up, and the smallest set of changes that makes Bubblewrap work, with a seccomp profile and a Kamal configuration for a job role.
|
|
129
132
|
|
|
130
133
|
## Documentation
|
|
131
134
|
|
data/ext/mxc/Cargo.toml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "mxc"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
edition = "2021"
|
|
5
5
|
authors = ["Carmine Paolino <carmine@paolino.me>"]
|
|
6
6
|
license = "MIT"
|
|
@@ -11,9 +11,9 @@ crate-type = ["cdylib"]
|
|
|
11
11
|
|
|
12
12
|
[dependencies]
|
|
13
13
|
magnus = "0.8.2"
|
|
14
|
-
# A fork of mxc-sdk 1.0.0 that adds resource limits (microsoft/mxc#1498)
|
|
15
|
-
# until MXC releases them.
|
|
16
|
-
mxc-sdk = { git = "https://github.com/crmne/microsoft-mxc", rev = "
|
|
14
|
+
# A fork of mxc-sdk 1.0.0 that adds resource limits (microsoft/mxc#1498) and
|
|
15
|
+
# a Landlock backend (microsoft/mxc#1494), until MXC releases them.
|
|
16
|
+
mxc-sdk = { git = "https://github.com/crmne/microsoft-mxc", rev = "d57b0d1aa3a2d552001fa54ef4ac4baaa733c26d" }
|
|
17
17
|
rb-sys = { version = "0.9.130", features = ["stable-api-compiled-fallback"] }
|
|
18
18
|
serde_json = "1"
|
|
19
19
|
|
data/ext/mxc/src/lib.rs
CHANGED
|
@@ -20,6 +20,16 @@ mod proxy;
|
|
|
20
20
|
use proxy::{Allowlist, EgressProxy};
|
|
21
21
|
|
|
22
22
|
const MAX_POLL: Duration = Duration::from_millis(50);
|
|
23
|
+
/// How long a sandbox that failed may keep its output streams open before
|
|
24
|
+
/// they are closed. Nothing it started runs on, so they end at once.
|
|
25
|
+
const DRAIN: Duration = Duration::from_secs(1);
|
|
26
|
+
|
|
27
|
+
fn drain_until(drained: impl Fn() -> bool) {
|
|
28
|
+
let deadline = Instant::now() + DRAIN;
|
|
29
|
+
while !drained() && Instant::now() < deadline {
|
|
30
|
+
std::thread::sleep(Duration::from_millis(5));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
23
33
|
|
|
24
34
|
fn without_gvl<F, R>(f: F) -> R
|
|
25
35
|
where
|
|
@@ -90,21 +100,14 @@ fn deny_all() -> NetworkEgressPolicy {
|
|
|
90
100
|
}
|
|
91
101
|
|
|
92
102
|
/// A request ready to start: the typed request, or, when it carries resource
|
|
93
|
-
/// limits, which MXC's typed V1 request cannot express yet,
|
|
94
|
-
/// as `1.1.0-alpha` JSON.
|
|
103
|
+
/// limits or names Landlock, which MXC's typed V1 request cannot express yet,
|
|
104
|
+
/// the same request as `1.1.0-alpha` JSON.
|
|
95
105
|
enum Request {
|
|
96
106
|
Typed(Box<ContainerRequest>),
|
|
97
107
|
Json(String),
|
|
98
108
|
}
|
|
99
109
|
|
|
100
110
|
impl Request {
|
|
101
|
-
fn run(self) -> Result<v1::ExecutionResult, v1::Error> {
|
|
102
|
-
match self {
|
|
103
|
-
Request::Typed(req) => v1::run(*req, Default::default()),
|
|
104
|
-
Request::Json(json) => mxc_sdk::__ffi::run_json(&json, false),
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
111
|
fn spawn(self) -> Result<MxcProcess, v1::Error> {
|
|
109
112
|
match self {
|
|
110
113
|
Request::Typed(req) => v1::spawn(*req, Default::default()),
|
|
@@ -113,6 +116,13 @@ impl Request {
|
|
|
113
116
|
}
|
|
114
117
|
}
|
|
115
118
|
|
|
119
|
+
fn landlock(ruby: &Ruby, opts: RHash) -> Result<bool, Error> {
|
|
120
|
+
Ok(match opt::<Symbol>(ruby, opts, "backend")? {
|
|
121
|
+
Some(backend) => backend.name()? == "landlock",
|
|
122
|
+
None => false,
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
116
126
|
fn resources(ruby: &Ruby, opts: RHash) -> Result<Option<Value>, Error> {
|
|
117
127
|
let Some(limits) = opt::<RHash>(ruby, opts, "resources")? else {
|
|
118
128
|
return Ok(None);
|
|
@@ -140,8 +150,14 @@ fn action(action: Option<NetworkAction>) -> &'static str {
|
|
|
140
150
|
}
|
|
141
151
|
}
|
|
142
152
|
|
|
143
|
-
// Mirrors the 1.0.0 document MXC's typed V1 request produces, plus `resources
|
|
144
|
-
|
|
153
|
+
// Mirrors the 1.0.0 document MXC's typed V1 request produces, plus `resources`
|
|
154
|
+
// and the `landlock` containment.
|
|
155
|
+
fn request_json(
|
|
156
|
+
ruby: &Ruby,
|
|
157
|
+
req: &ContainerRequest,
|
|
158
|
+
resources: Option<Value>,
|
|
159
|
+
landlock: bool,
|
|
160
|
+
) -> Result<String, Error> {
|
|
145
161
|
let filesystem = req.filesystem.clone().unwrap_or_default();
|
|
146
162
|
let mut process = json!({
|
|
147
163
|
"commandLine": req.command,
|
|
@@ -167,8 +183,10 @@ fn request_json(ruby: &Ruby, req: &ContainerRequest, resources: Value) -> Result
|
|
|
167
183
|
"readonlyPaths": filesystem.readonly_paths,
|
|
168
184
|
"deniedPaths": filesystem.denied_paths,
|
|
169
185
|
},
|
|
170
|
-
"resources": resources,
|
|
171
186
|
});
|
|
187
|
+
if let Some(resources) = resources {
|
|
188
|
+
document["resources"] = resources;
|
|
189
|
+
}
|
|
172
190
|
if let Some(network) = &req.network {
|
|
173
191
|
let default = action(network.egress.as_ref().and_then(|egress| egress.default));
|
|
174
192
|
document["network"] = json!({ "egress": { "default": default } });
|
|
@@ -181,6 +199,7 @@ fn request_json(ruby: &Ruby, req: &ContainerRequest, resources: Value) -> Result
|
|
|
181
199
|
}
|
|
182
200
|
}
|
|
183
201
|
document["containment"] = match &req.containment {
|
|
202
|
+
_ if landlock => "landlock".into(),
|
|
184
203
|
Containment::Process => "process".into(),
|
|
185
204
|
Containment::Bubblewrap => "bubblewrap".into(),
|
|
186
205
|
Containment::Seatbelt(_) => {
|
|
@@ -212,9 +231,10 @@ fn prepare(
|
|
|
212
231
|
opts: RHash,
|
|
213
232
|
) -> Result<(Request, Option<EgressProxy>), Error> {
|
|
214
233
|
let (req, egress_proxy) = build_request(ruby, command, opts)?;
|
|
234
|
+
let landlock = landlock(ruby, opts)?;
|
|
215
235
|
let request = match resources(ruby, opts)? {
|
|
216
|
-
|
|
217
|
-
|
|
236
|
+
None if !landlock => Request::Typed(Box::new(req)),
|
|
237
|
+
resources => Request::Json(request_json(ruby, &req, resources, landlock)?),
|
|
218
238
|
};
|
|
219
239
|
Ok((request, egress_proxy))
|
|
220
240
|
}
|
|
@@ -291,6 +311,8 @@ fn build_request(
|
|
|
291
311
|
req.containment = match backend.name()?.as_ref() {
|
|
292
312
|
"auto" => Containment::Process,
|
|
293
313
|
"bubblewrap" => Containment::Bubblewrap,
|
|
314
|
+
// The typed request has no Landlock; `request_json` names it.
|
|
315
|
+
"landlock" => Containment::Process,
|
|
294
316
|
"seatbelt" => Containment::Seatbelt(SeatbeltConfig::default()),
|
|
295
317
|
"process_container" => {
|
|
296
318
|
Containment::ProcessContainer(ProcessContainerConfig::default())
|
|
@@ -301,19 +323,62 @@ fn build_request(
|
|
|
301
323
|
Ok((req, egress_proxy))
|
|
302
324
|
}
|
|
303
325
|
|
|
304
|
-
|
|
326
|
+
// A wait that fails, such as for a sandbox that never started the command,
|
|
327
|
+
// is reported as `failure` rather than raised, so Ruby reads it alongside
|
|
328
|
+
// what the sandbox printed.
|
|
329
|
+
fn outcome_hash(ruby: &Ruby, outcome: std::io::Result<WaitResult>) -> Result<RHash, Error> {
|
|
305
330
|
let h = ruby.hash_new();
|
|
306
|
-
match outcome {
|
|
307
|
-
WaitResult::Exited(code) =>
|
|
308
|
-
|
|
309
|
-
|
|
331
|
+
let (timed_out, exit_code) = match outcome {
|
|
332
|
+
Ok(WaitResult::Exited(code)) => (false, Some(code)),
|
|
333
|
+
Ok(WaitResult::TimedOut) => (true, None),
|
|
334
|
+
Err(e) => {
|
|
335
|
+
h.aset(ruby.to_symbol("failure"), e.to_string())?;
|
|
336
|
+
(false, None)
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
h.aset(ruby.to_symbol("timed_out"), timed_out)?;
|
|
340
|
+
h.aset(ruby.to_symbol("exit_code"), exit_code)?;
|
|
341
|
+
Ok(h)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
type Outcome = (std::io::Result<WaitResult>, Vec<String>, Option<ExecutionMetadata>);
|
|
345
|
+
type Drain = std::thread::JoinHandle<std::io::Result<Vec<u8>>>;
|
|
346
|
+
|
|
347
|
+
fn drain(stream: Option<Box<dyn Read + Send>>) -> Drain {
|
|
348
|
+
std::thread::spawn(move || {
|
|
349
|
+
let mut buf = Vec::new();
|
|
350
|
+
if let Some(mut stream) = stream {
|
|
351
|
+
stream.read_to_end(&mut buf)?;
|
|
310
352
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
353
|
+
Ok(buf)
|
|
354
|
+
})
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
fn drained(handle: Drain) -> std::io::Result<Vec<u8>> {
|
|
358
|
+
handle
|
|
359
|
+
.join()
|
|
360
|
+
.unwrap_or_else(|_| Err(std::io::Error::other("output capture thread panicked")))
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// MXC's `wait_with_output`, except that a failed wait keeps the output: when
|
|
364
|
+
// Bubblewrap cannot set a sandbox up, its reason is on stderr.
|
|
365
|
+
fn capture(mut child: MxcProcess) -> std::io::Result<(Outcome, Vec<u8>, Vec<u8>)> {
|
|
366
|
+
let closers = [child.stdout_closer(), child.stderr_closer()];
|
|
367
|
+
let stdout = drain(child.take_stdout());
|
|
368
|
+
let stderr = drain(child.take_stderr());
|
|
369
|
+
let outcome = child.wait();
|
|
370
|
+
let warnings = child.warnings();
|
|
371
|
+
let metadata = child.output_metadata().cloned();
|
|
372
|
+
drop(child);
|
|
373
|
+
if outcome.is_err() {
|
|
374
|
+
drain_until(|| stdout.is_finished() && stderr.is_finished());
|
|
375
|
+
}
|
|
376
|
+
if !matches!(outcome, Ok(WaitResult::Exited(_))) {
|
|
377
|
+
for closer in closers.iter().flatten() {
|
|
378
|
+
closer.close();
|
|
314
379
|
}
|
|
315
380
|
}
|
|
316
|
-
Ok(
|
|
381
|
+
Ok(((outcome, warnings, metadata), drained(stdout)?, drained(stderr)?))
|
|
317
382
|
}
|
|
318
383
|
|
|
319
384
|
fn snake_case(name: &str) -> String {
|
|
@@ -351,18 +416,20 @@ fn backends(ruby: &Ruby) -> Result<RArray, Error> {
|
|
|
351
416
|
|
|
352
417
|
fn run(ruby: &Ruby, command: String, opts: RHash) -> Result<RHash, Error> {
|
|
353
418
|
let (req, mut egress_proxy) = prepare(ruby, command, opts)?;
|
|
354
|
-
let result = without_gvl(move || req.
|
|
419
|
+
let result = without_gvl(move || req.spawn().map(capture));
|
|
355
420
|
let blocked = egress_proxy.as_mut().map(|p| {
|
|
356
421
|
p.stop();
|
|
357
422
|
p.blocked()
|
|
358
423
|
});
|
|
359
|
-
let
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
h
|
|
363
|
-
h.aset(ruby.to_symbol("
|
|
424
|
+
let ((outcome, warnings, metadata), stdout, stderr) = result
|
|
425
|
+
.map_err(|e| mxc_error(ruby, e))?
|
|
426
|
+
.map_err(|e| io_error(ruby, e))?;
|
|
427
|
+
let h = outcome_hash(ruby, outcome)?;
|
|
428
|
+
h.aset(ruby.to_symbol("stdout"), ruby.str_from_slice(&stdout))?;
|
|
429
|
+
h.aset(ruby.to_symbol("stderr"), ruby.str_from_slice(&stderr))?;
|
|
430
|
+
h.aset(ruby.to_symbol("warnings"), warnings)?;
|
|
364
431
|
h.aset(ruby.to_symbol("blocked"), blocked.unwrap_or_default())?;
|
|
365
|
-
usage_into(ruby, h,
|
|
432
|
+
usage_into(ruby, h, metadata.as_ref())?;
|
|
366
433
|
Ok(h)
|
|
367
434
|
}
|
|
368
435
|
|
|
@@ -426,6 +493,21 @@ impl Process {
|
|
|
426
493
|
self.pid
|
|
427
494
|
}
|
|
428
495
|
|
|
496
|
+
fn warnings(&self) -> Vec<String> {
|
|
497
|
+
match self.child.lock().unwrap().as_ref() {
|
|
498
|
+
Some(child) => child.warnings(),
|
|
499
|
+
None => Vec::new(),
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// A reader blocked in `read` holds its stream's lock, and one that reached
|
|
504
|
+
// the end leaves `None`.
|
|
505
|
+
fn drained(&self) -> bool {
|
|
506
|
+
[&self.stdout, &self.stderr]
|
|
507
|
+
.iter()
|
|
508
|
+
.all(|stream| matches!(stream.try_lock(), Ok(guard) if guard.is_none()))
|
|
509
|
+
}
|
|
510
|
+
|
|
429
511
|
fn read_stdout(ruby: &Ruby, rb_self: &Self, max: usize) -> Result<Option<RString>, Error> {
|
|
430
512
|
read_from(ruby, &rb_self.stdout, max)
|
|
431
513
|
}
|
|
@@ -463,7 +545,7 @@ impl Process {
|
|
|
463
545
|
|
|
464
546
|
// MxcProcess::kill and ::wait both need the handle exclusively, so waiting
|
|
465
547
|
// polls and releases the lock between polls to let kill in.
|
|
466
|
-
fn await_exit(&self) -> std::io::Result<
|
|
548
|
+
fn await_exit(&self) -> std::io::Result<Outcome> {
|
|
467
549
|
let mut nap = Duration::from_millis(1);
|
|
468
550
|
loop {
|
|
469
551
|
{
|
|
@@ -472,8 +554,8 @@ impl Process {
|
|
|
472
554
|
.as_mut()
|
|
473
555
|
.ok_or_else(|| std::io::Error::other("the process was already waited for"))?;
|
|
474
556
|
let expired = self.deadline.is_some_and(|d| Instant::now() >= d);
|
|
475
|
-
if expired || child.try_wait()
|
|
476
|
-
let outcome = child.wait()
|
|
557
|
+
if expired || !matches!(child.try_wait(), Ok(None)) {
|
|
558
|
+
let outcome = child.wait();
|
|
477
559
|
let warnings = child.warnings();
|
|
478
560
|
let metadata = child.output_metadata().cloned();
|
|
479
561
|
guard.take();
|
|
@@ -499,7 +581,10 @@ impl Process {
|
|
|
499
581
|
None => Vec::new(),
|
|
500
582
|
};
|
|
501
583
|
let (outcome, warnings, metadata) = res.map_err(|e| io_error(ruby, e))?;
|
|
502
|
-
if
|
|
584
|
+
if outcome.is_err() {
|
|
585
|
+
without_gvl(|| drain_until(|| rb_self.drained()));
|
|
586
|
+
}
|
|
587
|
+
if !matches!(outcome, Ok(WaitResult::Exited(_))) {
|
|
503
588
|
if let Some(c) = &rb_self.stdout_closer {
|
|
504
589
|
c.close();
|
|
505
590
|
}
|
|
@@ -524,6 +609,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
524
609
|
native.define_singleton_method("spawn", function!(spawn, 2))?;
|
|
525
610
|
let process = native.define_class("Process", ruby.class_object())?;
|
|
526
611
|
process.define_method("pid", method!(Process::pid, 0))?;
|
|
612
|
+
process.define_method("warnings", method!(Process::warnings, 0))?;
|
|
527
613
|
process.define_method("read_stdout", method!(Process::read_stdout, 1))?;
|
|
528
614
|
process.define_method("read_stderr", method!(Process::read_stderr, 1))?;
|
|
529
615
|
process.define_method("write", method!(Process::write, 1))?;
|
data/lib/mxc/availability.rb
CHANGED
|
@@ -16,9 +16,13 @@ module MXC
|
|
|
16
16
|
# Returns a suggestion for making the host supported, or nil.
|
|
17
17
|
attr_reader :remediation
|
|
18
18
|
|
|
19
|
+
# Returns the backend sandboxes run on, such as <tt>:bubblewrap</tt>, or <tt>:landlock</tt>
|
|
20
|
+
# when <tt>:auto</tt> falls back to it. Returns nil when no sandbox can start.
|
|
21
|
+
attr_reader :runs_on
|
|
22
|
+
|
|
19
23
|
def self.probe(backend) # :nodoc:
|
|
20
24
|
result = Sandbox.new(backend:, timeout: 10).run('exit 0')
|
|
21
|
-
return new(backend:, supported: true) if result.success?
|
|
25
|
+
return new(backend:, supported: true, runs_on: result.backend) if result.success?
|
|
22
26
|
|
|
23
27
|
outcome = result.timed_out? ? 'timed out' : "exited with #{result.exit_code}"
|
|
24
28
|
new(backend:, supported: false, reason: "A sandboxed `exit 0` #{outcome}: #{result.stderr.strip}")
|
|
@@ -26,8 +30,9 @@ module MXC
|
|
|
26
30
|
new(backend:, supported: false, reason: e.message, remediation: e.remediation)
|
|
27
31
|
end
|
|
28
32
|
|
|
29
|
-
def initialize(backend:, supported:, reason: nil, remediation: nil) # :nodoc:
|
|
33
|
+
def initialize(backend:, supported:, reason: nil, remediation: nil, runs_on: nil) # :nodoc:
|
|
30
34
|
@backend = backend
|
|
35
|
+
@runs_on = runs_on
|
|
31
36
|
@supported = supported
|
|
32
37
|
@reason = reason
|
|
33
38
|
@remediation = remediation
|
|
@@ -39,7 +44,8 @@ module MXC
|
|
|
39
44
|
end
|
|
40
45
|
|
|
41
46
|
def inspect # :nodoc:
|
|
42
|
-
"#<MXC::Availability backend=#{backend.inspect} supported=#{supported?}
|
|
47
|
+
"#<MXC::Availability backend=#{backend.inspect} supported=#{supported?} runs_on=#{runs_on.inspect} " \
|
|
48
|
+
"reason=#{reason.inspect}>"
|
|
43
49
|
end
|
|
44
50
|
end
|
|
45
51
|
end
|
data/lib/mxc/configuration.rb
CHANGED
|
@@ -15,7 +15,8 @@ module MXC
|
|
|
15
15
|
@timeout = nil
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
# Sets the default backend: <tt>:auto</tt>, <tt>:bubblewrap</tt>,
|
|
18
|
+
# Sets the default backend: <tt>:auto</tt>, <tt>:bubblewrap</tt>, <tt>:landlock</tt>,
|
|
19
|
+
# <tt>:seatbelt</tt>, or <tt>:process_container</tt>.
|
|
19
20
|
def backend=(name)
|
|
20
21
|
@backend = Sandbox.backend_name(name)
|
|
21
22
|
end
|
data/lib/mxc/native.rb
CHANGED
|
@@ -13,6 +13,9 @@ module MXC
|
|
|
13
13
|
MISSING_BUBBLEWRAP = /bwrap\) is not installed/
|
|
14
14
|
INSTALL_BUBBLEWRAP = 'Install Bubblewrap 0.5.0 or newer (Debian/Ubuntu: apt-get install bubblewrap; ' \
|
|
15
15
|
'Fedora: dnf install bubblewrap; Arch: pacman -S bubblewrap)'
|
|
16
|
+
LANDLOCK_REFUSAL = /\ALandlock: /
|
|
17
|
+
LANDLOCK_POLICY = 'Change the policy as the message says, or run on Bubblewrap: a sandbox with backend :auto ' \
|
|
18
|
+
'runs on Landlock only where Bubblewrap cannot create its namespaces'
|
|
16
19
|
|
|
17
20
|
def self.backend_attributes
|
|
18
21
|
backends.map { |attributes| attributes.merge(name: BACKEND_NAMES.fetch(attributes[:name], attributes[:name])) }
|
|
@@ -21,8 +24,12 @@ module MXC
|
|
|
21
24
|
def self.error(message, code: nil, remediation: nil, **)
|
|
22
25
|
if message.match?(MISSING_BUBBLEWRAP)
|
|
23
26
|
UnsupportedError.new(message, code: :backend_unavailable, remediation: remediation || INSTALL_BUBBLEWRAP, **)
|
|
27
|
+
elsif message.match?(LANDLOCK_REFUSAL)
|
|
28
|
+
UnsupportedError.new(message, code:, remediation: remediation || LANDLOCK_POLICY, **)
|
|
29
|
+
elsif UNSUPPORTED_CODES.include?(code)
|
|
30
|
+
UnsupportedError.new(message, code:, remediation: remediation || StartupFailure.hint(message), **)
|
|
24
31
|
else
|
|
25
|
-
|
|
32
|
+
Error.new(message, code:, remediation:, **)
|
|
26
33
|
end
|
|
27
34
|
end
|
|
28
35
|
end
|
data/lib/mxc/preflight.rb
CHANGED
|
@@ -16,16 +16,27 @@ module MXC
|
|
|
16
16
|
'Linux and Windows support it'
|
|
17
17
|
PROCESS_LIMIT = 'Leave processes out of the limits. Only Windows can count the processes of one sandbox; ' \
|
|
18
18
|
'in a container, limit them with docker run --pids-limit'
|
|
19
|
+
LANDLOCK_NETWORK = 'Use network: false, or the :bubblewrap backend on a host that allows unprivileged user ' \
|
|
20
|
+
'namespaces. Landlock cannot tell one address from another, so it could only allow the ' \
|
|
21
|
+
"whole host network, the host's loopback and inbound connections included"
|
|
19
22
|
|
|
20
23
|
module_function
|
|
21
24
|
|
|
22
|
-
def check!(network:, denied: [], resources: nil)
|
|
23
|
-
error = problem(network:, denied:, resources:)
|
|
25
|
+
def check!(network:, denied: [], resources: nil, backend: :auto)
|
|
26
|
+
error = problem(network:, denied:, resources:, backend:)
|
|
24
27
|
raise error if error
|
|
25
28
|
end
|
|
26
29
|
|
|
27
|
-
def problem(network:, denied: [], resources: nil, path: ENV.fetch('PATH', ''), tun: TUN)
|
|
28
|
-
resource_problem(resources) || host_problem(network, denied, path, tun)
|
|
30
|
+
def problem(network:, denied: [], resources: nil, backend: :auto, path: ENV.fetch('PATH', ''), tun: TUN)
|
|
31
|
+
resource_problem(resources) || landlock_problem(network, backend) || host_problem(network, denied, path, tun)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def landlock_problem(network, backend)
|
|
35
|
+
return unless network && backend == :landlock
|
|
36
|
+
|
|
37
|
+
kind = network.is_a?(Hash) ? 'Host allow-lists are' : 'Network access is'
|
|
38
|
+
UnsupportedError.new("#{kind} not supported on Landlock", code: :backend_unavailable,
|
|
39
|
+
remediation: LANDLOCK_NETWORK)
|
|
29
40
|
end
|
|
30
41
|
|
|
31
42
|
def resource_problem(resources)
|
data/lib/mxc/sandbox.rb
CHANGED
|
@@ -28,7 +28,8 @@ module MXC
|
|
|
28
28
|
#
|
|
29
29
|
# SkillSandbox.new.run("python3 app/skills/build.py")
|
|
30
30
|
class Sandbox
|
|
31
|
-
BACKENDS = %i[auto bubblewrap seatbelt process_container].freeze # :nodoc:
|
|
31
|
+
BACKENDS = %i[auto bubblewrap landlock seatbelt process_container].freeze # :nodoc:
|
|
32
|
+
LANDLOCK_FALLBACK = /runs on Landlock instead/ # :nodoc:
|
|
32
33
|
OPTIONS = %i[readable writable denied network env directory timeout backend limits priority].freeze # :nodoc:
|
|
33
34
|
HOST_RESOLVER_SOCKETS = %w[/run/systemd/resolve].freeze # :nodoc:
|
|
34
35
|
WINDOWS_ENV = %w[SYSTEMROOT WINDIR SYSTEMDRIVE COMSPEC PATHEXT PATH LOCALAPPDATA TEMP TMP
|
|
@@ -218,8 +219,12 @@ module MXC
|
|
|
218
219
|
end
|
|
219
220
|
|
|
220
221
|
# Sets the backend: <tt>:auto</tt> (Bubblewrap on Linux, Seatbelt on macOS,
|
|
221
|
-
# ProcessContainer on Windows), <tt>:bubblewrap</tt>, <tt>:seatbelt</tt>,
|
|
222
|
-
# <tt>:process_container</tt>.
|
|
222
|
+
# ProcessContainer on Windows), <tt>:bubblewrap</tt>, <tt>:landlock</tt>, <tt>:seatbelt</tt>,
|
|
223
|
+
# or <tt>:process_container</tt>.
|
|
224
|
+
#
|
|
225
|
+
# On Linux, <tt>:auto</tt> runs on Landlock when Bubblewrap cannot create its namespaces,
|
|
226
|
+
# and says so in Result#warnings. <tt>:bubblewrap</tt> never does. <tt>:landlock</tt>
|
|
227
|
+
# needs Linux 6.2 or newer and runs without network access.
|
|
223
228
|
def with_backend(name)
|
|
224
229
|
@settings[:backend] = name && self.class.backend_name(name)
|
|
225
230
|
self
|
|
@@ -279,7 +284,7 @@ module MXC
|
|
|
279
284
|
return stream(*command, &) if block_given?
|
|
280
285
|
|
|
281
286
|
outcome = StartupFailure.check!(Native.run(command_line(command), request))
|
|
282
|
-
Result.new(**outcome, backend: resolved_backend)
|
|
287
|
+
Result.new(**outcome, backend: resolved_backend(outcome[:warnings]))
|
|
283
288
|
end
|
|
284
289
|
|
|
285
290
|
# Starts +command+ and returns its Process without waiting for it. Takes the command
|
|
@@ -287,7 +292,8 @@ module MXC
|
|
|
287
292
|
#
|
|
288
293
|
# sandbox.spawn("python3", "-i", "-q")
|
|
289
294
|
def spawn(*command)
|
|
290
|
-
|
|
295
|
+
native = Native.spawn(command_line(command), request)
|
|
296
|
+
Process.new(native, backend: resolved_backend(native.warnings))
|
|
291
297
|
end
|
|
292
298
|
|
|
293
299
|
# Returns true when this host can run this sandbox, including its network policy.
|
|
@@ -298,8 +304,10 @@ module MXC
|
|
|
298
304
|
# Returns the Availability of this sandbox on this host, with the reason and
|
|
299
305
|
# remediation when it cannot run.
|
|
300
306
|
def availability
|
|
301
|
-
problem = Preflight.problem(network:, denied:, resources:)
|
|
302
|
-
|
|
307
|
+
problem = Preflight.problem(network:, denied:, resources:, backend:)
|
|
308
|
+
host = MXC.availability(backend:) unless problem
|
|
309
|
+
problem ||= Preflight.problem(network:, backend: :landlock) if host&.runs_on == :landlock
|
|
310
|
+
return host unless problem
|
|
303
311
|
|
|
304
312
|
Availability.new(backend:, supported: false, reason: problem.message, remediation: problem.remediation)
|
|
305
313
|
end
|
|
@@ -404,15 +412,16 @@ module MXC
|
|
|
404
412
|
arguments.map { |argument| WindowsArguments.quote(argument) }.join(' ')
|
|
405
413
|
end
|
|
406
414
|
|
|
407
|
-
def resolved_backend
|
|
415
|
+
def resolved_backend(warnings)
|
|
408
416
|
return backend unless backend == :auto
|
|
409
417
|
return :process_container if Preflight.windows?
|
|
418
|
+
return :seatbelt unless Preflight.linux?
|
|
410
419
|
|
|
411
|
-
|
|
420
|
+
warnings.any?(LANDLOCK_FALLBACK) ? :landlock : :bubblewrap
|
|
412
421
|
end
|
|
413
422
|
|
|
414
423
|
def request
|
|
415
|
-
Preflight.check!(network:, denied:, resources:)
|
|
424
|
+
Preflight.check!(network:, denied:, resources:, backend:)
|
|
416
425
|
{
|
|
417
426
|
readonly: expand(readable),
|
|
418
427
|
readwrite: expand(writable),
|
data/lib/mxc/startup_failure.rb
CHANGED
|
@@ -7,7 +7,8 @@ module MXC
|
|
|
7
7
|
/setting up uid map/i, /unshare.*Operation not permitted/i)
|
|
8
8
|
PROC = /Can't mount proc|mount proc on/i
|
|
9
9
|
HOST_PROBLEMS = {
|
|
10
|
-
NAMESPACES => 'Unprivileged user namespaces are blocked.
|
|
10
|
+
NAMESPACES => 'Unprivileged user namespaces are blocked. Use backend :auto or :landlock to run on Landlock ' \
|
|
11
|
+
'instead, or allow them for Bubblewrap: on a host, enable kernel.unprivileged_userns_clone=1 ' \
|
|
11
12
|
'and, on Ubuntu 23.10 and later, allow bwrap in AppArmor ' \
|
|
12
13
|
'(kernel.apparmor_restrict_unprivileged_userns). ' \
|
|
13
14
|
"In Docker, run with a seccomp profile that allows namespaces: #{DOCKER_GUIDE}",
|
|
@@ -15,23 +16,27 @@ module MXC
|
|
|
15
16
|
"In Docker, see #{DOCKER_GUIDE}"
|
|
16
17
|
}.freeze
|
|
17
18
|
CHDIR = /Can't chdir/i
|
|
19
|
+
BWRAP_LINE = /^bwrap: .*$/
|
|
18
20
|
|
|
19
21
|
module_function
|
|
20
22
|
|
|
21
23
|
def detect(outcome)
|
|
22
|
-
|
|
24
|
+
failure = outcome[:failure] or return
|
|
25
|
+
reason = outcome[:stderr].to_s.scan(BWRAP_LINE).last
|
|
26
|
+
return Error.new(failure, code: :backend_error) unless reason
|
|
23
27
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
message = "Bubblewrap could not start the sandbox: #{stderr.strip}"
|
|
28
|
-
_, hint = HOST_PROBLEMS.find { |pattern, _| stderr.match?(pattern) }
|
|
28
|
+
message = "Bubblewrap could not start the sandbox: #{reason}"
|
|
29
|
+
hint = hint(reason)
|
|
29
30
|
return UnsupportedError.new(message, code: :backend_unavailable, remediation: hint, operation: 'bwrap') if hint
|
|
30
31
|
|
|
31
|
-
remediation = 'Make the directory readable or writable in the sandbox' if
|
|
32
|
+
remediation = 'Make the directory readable or writable in the sandbox' if reason.match?(CHDIR)
|
|
32
33
|
Error.new(message, code: :backend_error, remediation:, operation: 'bwrap')
|
|
33
34
|
end
|
|
34
35
|
|
|
36
|
+
def hint(message)
|
|
37
|
+
HOST_PROBLEMS.find { |pattern, _| message.match?(pattern) }&.last
|
|
38
|
+
end
|
|
39
|
+
|
|
35
40
|
def check!(outcome)
|
|
36
41
|
error = detect(outcome)
|
|
37
42
|
raise error if error
|
data/lib/mxc/version.rb
CHANGED
data/lib/mxc.rb
CHANGED
|
@@ -87,7 +87,8 @@ module MXC
|
|
|
87
87
|
Native.backend_attributes.map { |attributes| Backend.new(**attributes) }
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
-
# Returns true when this host supports +capability+ with the configured backend.
|
|
90
|
+
# Returns true when this host supports +capability+ with the configured backend. Where
|
|
91
|
+
# sandboxes run on Landlock, there is no <tt>:network</tt> or <tt>:network_policy</tt>.
|
|
91
92
|
#
|
|
92
93
|
# +capability+ is <tt>:network</tt> for unrestricted network access,
|
|
93
94
|
# <tt>:network_policy</tt> for host allow-lists, <tt>:cpu_time_limit</tt>,
|
|
@@ -100,8 +101,8 @@ module MXC
|
|
|
100
101
|
return false unless supported?
|
|
101
102
|
|
|
102
103
|
case capability
|
|
103
|
-
when :network then Preflight.problem(network: true).nil?
|
|
104
|
-
when :network_policy then Preflight.problem(network: { allow: [] }).nil? && proxy_enforcement?
|
|
104
|
+
when :network then Preflight.problem(network: true, backend: runs_on).nil?
|
|
105
|
+
when :network_policy then Preflight.problem(network: { allow: [] }, backend: runs_on).nil? && proxy_enforcement?
|
|
105
106
|
when *RESOURCE_CAPABILITIES.keys then Preflight.resource_problem(RESOURCE_CAPABILITIES[capability]).nil?
|
|
106
107
|
else configured_backends.any? { |backend| backend.supports?(capability) }
|
|
107
108
|
end
|
|
@@ -109,6 +110,10 @@ module MXC
|
|
|
109
110
|
|
|
110
111
|
private
|
|
111
112
|
|
|
113
|
+
def runs_on
|
|
114
|
+
availability.runs_on
|
|
115
|
+
end
|
|
116
|
+
|
|
112
117
|
def proxy_enforcement?
|
|
113
118
|
!Preflight.linux? || configured_backends.any? { |backend| backend.supports?(:proxy_enforcement) }
|
|
114
119
|
end
|