mxc 0.1.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 +35 -3
- data/NOTICE +3 -2
- data/README.md +18 -6
- data/ext/mxc/Cargo.toml +5 -2
- data/ext/mxc/src/lib.rs +264 -28
- 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 +34 -3
- data/lib/mxc/result.rb +18 -3
- data/lib/mxc/sandbox.rb +132 -15
- data/lib/mxc/startup_failure.rb +13 -8
- data/lib/mxc/version.rb +1 -1
- data/lib/mxc.rb +20 -4
- 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,19 +593,19 @@ 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",
|
|
569
600
|
"rb-sys",
|
|
570
601
|
"rb-sys-env",
|
|
602
|
+
"serde_json",
|
|
571
603
|
]
|
|
572
604
|
|
|
573
605
|
[[package]]
|
|
574
606
|
name = "mxc-sdk"
|
|
575
607
|
version = "1.0.0"
|
|
576
|
-
source = "
|
|
577
|
-
checksum = "e4866ca589a68edc8e931eba58eed9253ad1ce670e974aedc4830a1d94bca6cd"
|
|
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
|
@@ -5,8 +5,9 @@ This gem is an unofficial Ruby binding to MXC. It is not affiliated with or
|
|
|
5
5
|
endorsed by Microsoft.
|
|
6
6
|
|
|
7
7
|
The native extension compiles and links the mxc-sdk crate
|
|
8
|
-
(https://github.com/microsoft/mxc, https://crates.io/crates/mxc-sdk),
|
|
9
|
-
|
|
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
|
+
and a Landlock backend, which is distributed under the MIT License:
|
|
10
11
|
|
|
11
12
|
MIT License
|
|
12
13
|
|
data/README.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
---
|
|
16
16
|
|
|
17
|
-
Your app runs code it did not write: scripts a model generated, skills users uploaded, build steps from someone else. MXC for Ruby runs them in a sandbox that decides what they can read, write, and reach, and how
|
|
17
|
+
Your app runs code it did not write: scripts a model generated, skills users uploaded, build steps from someone else. MXC for Ruby runs them in a sandbox that decides what they can read, write, and reach, how long they get, and how much CPU and memory they can use.
|
|
18
18
|
|
|
19
19
|
```ruby
|
|
20
20
|
result = MXC.run("python3 build.py", readable: "app/skills", writable: "tmp/out", timeout: 30)
|
|
@@ -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.
|
|
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
|
|
@@ -68,6 +70,14 @@ result = sandbox.run("pip download --no-deps -d /tmp/wheels requests")
|
|
|
68
70
|
result.blocked # => hosts the allow-list refused
|
|
69
71
|
```
|
|
70
72
|
|
|
73
|
+
Limit what a command can use, and lower its priority so your app stays responsive:
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
result = sandbox.with_limits(cpu_time: 2.minutes, memory: 2.gigabytes).with_priority(:low).run("python3 build.py")
|
|
77
|
+
result.cpu_time # => 3.42
|
|
78
|
+
result.limit_exceeded # => nil, or :cpu_time when it ran out
|
|
79
|
+
```
|
|
80
|
+
|
|
71
81
|
Stream output as it arrives:
|
|
72
82
|
|
|
73
83
|
```ruby
|
|
@@ -92,6 +102,7 @@ class SkillSandbox < MXC::Sandbox
|
|
|
92
102
|
writable "tmp/skills"
|
|
93
103
|
network false
|
|
94
104
|
timeout 30
|
|
105
|
+
limits cpu_time: 60, memory: 1.gigabyte
|
|
95
106
|
end
|
|
96
107
|
|
|
97
108
|
SkillSandbox.new.run("python3 app/skills/build.py")
|
|
@@ -108,15 +119,16 @@ MXC.backends # => [#<MXC::Backend name=:bubblewrap ...>]
|
|
|
108
119
|
|
|
109
120
|
## Platforms
|
|
110
121
|
|
|
111
|
-
| Platform | Backend | 0.
|
|
122
|
+
| Platform | Backend | 0.3.0 |
|
|
112
123
|
| --- | --- | --- |
|
|
113
|
-
| Linux | Bubblewrap | Supported and tested |
|
|
114
|
-
|
|
|
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 |
|
|
126
|
+
| macOS | Seatbelt | Supported and tested, without memory or process limits |
|
|
115
127
|
| Windows 11 24H2 and later | ProcessContainer | Supported and tested, without host allow-lists |
|
|
116
128
|
|
|
117
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.
|
|
118
130
|
|
|
119
|
-
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.
|
|
120
132
|
|
|
121
133
|
## Documentation
|
|
122
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,8 +11,11 @@ crate-type = ["cdylib"]
|
|
|
11
11
|
|
|
12
12
|
[dependencies]
|
|
13
13
|
magnus = "0.8.2"
|
|
14
|
-
mxc-sdk
|
|
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" }
|
|
15
17
|
rb-sys = { version = "0.9.130", features = ["stable-api-compiled-fallback"] }
|
|
18
|
+
serde_json = "1"
|
|
16
19
|
|
|
17
20
|
[build-dependencies]
|
|
18
21
|
rb-sys-env = "0.2.2"
|
data/ext/mxc/src/lib.rs
CHANGED
|
@@ -9,15 +9,27 @@ use magnus::{
|
|
|
9
9
|
use mxc_sdk::v1::{
|
|
10
10
|
self,
|
|
11
11
|
configs::{ProcessContainerConfig, SeatbeltConfig},
|
|
12
|
-
Containment, ContainerRequest, FilesystemPolicy, MxcProcess,
|
|
13
|
-
NetworkEgressPolicy, NetworkPolicy, NetworkRuntimeConfig,
|
|
12
|
+
Containment, ContainerRequest, ExecutionMetadata, FilesystemPolicy, MxcProcess,
|
|
13
|
+
NetworkAction, NetworkEgressPolicy, NetworkPolicy, NetworkRuntimeConfig, ResourceLimit,
|
|
14
|
+
StreamCloser, WaitResult,
|
|
14
15
|
};
|
|
16
|
+
use serde_json::{json, Map, Value};
|
|
15
17
|
|
|
16
18
|
mod proxy;
|
|
17
19
|
|
|
18
20
|
use proxy::{Allowlist, EgressProxy};
|
|
19
21
|
|
|
20
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
|
+
}
|
|
21
33
|
|
|
22
34
|
fn without_gvl<F, R>(f: F) -> R
|
|
23
35
|
where
|
|
@@ -87,6 +99,162 @@ fn deny_all() -> NetworkEgressPolicy {
|
|
|
87
99
|
}
|
|
88
100
|
}
|
|
89
101
|
|
|
102
|
+
/// A request ready to start: the typed request, or, when it carries resource
|
|
103
|
+
/// limits or names Landlock, which MXC's typed V1 request cannot express yet,
|
|
104
|
+
/// the same request as `1.1.0-alpha` JSON.
|
|
105
|
+
enum Request {
|
|
106
|
+
Typed(Box<ContainerRequest>),
|
|
107
|
+
Json(String),
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
impl Request {
|
|
111
|
+
fn spawn(self) -> Result<MxcProcess, v1::Error> {
|
|
112
|
+
match self {
|
|
113
|
+
Request::Typed(req) => v1::spawn(*req, Default::default()),
|
|
114
|
+
Request::Json(json) => mxc_sdk::__ffi::spawn_container_json(&json, false),
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
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
|
+
|
|
126
|
+
fn resources(ruby: &Ruby, opts: RHash) -> Result<Option<Value>, Error> {
|
|
127
|
+
let Some(limits) = opt::<RHash>(ruby, opts, "resources")? else {
|
|
128
|
+
return Ok(None);
|
|
129
|
+
};
|
|
130
|
+
let mut resources = Map::new();
|
|
131
|
+
if let Some(ms) = opt::<u64>(ruby, limits, "cpu_time_ms")? {
|
|
132
|
+
resources.insert("cpuTimeMs".into(), ms.into());
|
|
133
|
+
}
|
|
134
|
+
if let Some(mb) = opt::<u64>(ruby, limits, "memory_mb")? {
|
|
135
|
+
resources.insert("memoryMb".into(), mb.into());
|
|
136
|
+
}
|
|
137
|
+
if let Some(count) = opt::<u32>(ruby, limits, "max_processes")? {
|
|
138
|
+
resources.insert("maxProcesses".into(), count.into());
|
|
139
|
+
}
|
|
140
|
+
if let Some(priority) = opt::<String>(ruby, limits, "priority")? {
|
|
141
|
+
resources.insert("priority".into(), priority.into());
|
|
142
|
+
}
|
|
143
|
+
Ok(Some(Value::Object(resources)))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
fn action(action: Option<NetworkAction>) -> &'static str {
|
|
147
|
+
match action {
|
|
148
|
+
Some(NetworkAction::Allow) => "allow",
|
|
149
|
+
_ => "deny",
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
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> {
|
|
161
|
+
let filesystem = req.filesystem.clone().unwrap_or_default();
|
|
162
|
+
let mut process = json!({
|
|
163
|
+
"commandLine": req.command,
|
|
164
|
+
"timeout": req.timeout_ms.unwrap_or(0),
|
|
165
|
+
});
|
|
166
|
+
if let Some(cwd) = &req.working_directory {
|
|
167
|
+
process["cwd"] = cwd.clone().into();
|
|
168
|
+
}
|
|
169
|
+
if let Some(environment) = &req.environment {
|
|
170
|
+
let env: Vec<String> = environment
|
|
171
|
+
.iter()
|
|
172
|
+
.map(|(k, v)| format!("{k}={v}"))
|
|
173
|
+
.collect();
|
|
174
|
+
process["env"] = env.into();
|
|
175
|
+
}
|
|
176
|
+
let mut document = json!({
|
|
177
|
+
"version": "1.1.0-alpha",
|
|
178
|
+
"containerId": mxc_sdk::mxc_common::id::mint_random_token(),
|
|
179
|
+
"lifecycle": { "destroyOnExit": true, "preservePolicy": false },
|
|
180
|
+
"process": process,
|
|
181
|
+
"filesystem": {
|
|
182
|
+
"readwritePaths": filesystem.readwrite_paths,
|
|
183
|
+
"readonlyPaths": filesystem.readonly_paths,
|
|
184
|
+
"deniedPaths": filesystem.denied_paths,
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
if let Some(resources) = resources {
|
|
188
|
+
document["resources"] = resources;
|
|
189
|
+
}
|
|
190
|
+
if let Some(network) = &req.network {
|
|
191
|
+
let default = action(network.egress.as_ref().and_then(|egress| egress.default));
|
|
192
|
+
document["network"] = json!({ "egress": { "default": default } });
|
|
193
|
+
if let Some(proxy) = network
|
|
194
|
+
.runtime_config
|
|
195
|
+
.as_ref()
|
|
196
|
+
.and_then(|runtime| runtime.network_proxy.clone())
|
|
197
|
+
{
|
|
198
|
+
document["runtimeConfig"] = json!({ "networkProxy": proxy });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
document["containment"] = match &req.containment {
|
|
202
|
+
_ if landlock => "landlock".into(),
|
|
203
|
+
Containment::Process => "process".into(),
|
|
204
|
+
Containment::Bubblewrap => "bubblewrap".into(),
|
|
205
|
+
Containment::Seatbelt(_) => {
|
|
206
|
+
document["seatbelt"] = json!({
|
|
207
|
+
"guiAccess": false,
|
|
208
|
+
"nestedPty": true,
|
|
209
|
+
"keychainAccess": false,
|
|
210
|
+
"extraMachLookups": [],
|
|
211
|
+
});
|
|
212
|
+
"seatbelt".into()
|
|
213
|
+
}
|
|
214
|
+
Containment::ProcessContainer(_) => {
|
|
215
|
+
document["processContainer"] = json!({ "leastPrivilege": false, "capabilities": [] });
|
|
216
|
+
"processcontainer".into()
|
|
217
|
+
}
|
|
218
|
+
_ => {
|
|
219
|
+
return Err(arg_error(
|
|
220
|
+
ruby,
|
|
221
|
+
"unsupported backend for resource limits".into(),
|
|
222
|
+
))
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
Ok(document.to_string())
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
fn prepare(
|
|
229
|
+
ruby: &Ruby,
|
|
230
|
+
command: String,
|
|
231
|
+
opts: RHash,
|
|
232
|
+
) -> Result<(Request, Option<EgressProxy>), Error> {
|
|
233
|
+
let (req, egress_proxy) = build_request(ruby, command, opts)?;
|
|
234
|
+
let landlock = landlock(ruby, opts)?;
|
|
235
|
+
let request = match resources(ruby, opts)? {
|
|
236
|
+
None if !landlock => Request::Typed(Box::new(req)),
|
|
237
|
+
resources => Request::Json(request_json(ruby, &req, resources, landlock)?),
|
|
238
|
+
};
|
|
239
|
+
Ok((request, egress_proxy))
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
fn usage_into(ruby: &Ruby, h: RHash, metadata: Option<&ExecutionMetadata>) -> Result<(), Error> {
|
|
243
|
+
let usage = metadata.and_then(|metadata| metadata.resource_usage);
|
|
244
|
+
let cpu_time =
|
|
245
|
+
usage.map(|usage| (usage.user_cpu_time_us + usage.system_cpu_time_us) as f64 / 1_000_000.0);
|
|
246
|
+
let limit = usage.and_then(|usage| usage.limit_exceeded).map(|limit| {
|
|
247
|
+
ruby.to_symbol(match limit {
|
|
248
|
+
ResourceLimit::CpuTime => "cpu_time",
|
|
249
|
+
ResourceLimit::Memory => "memory",
|
|
250
|
+
ResourceLimit::MaxProcesses => "processes",
|
|
251
|
+
})
|
|
252
|
+
});
|
|
253
|
+
h.aset(ruby.to_symbol("cpu_time"), cpu_time)?;
|
|
254
|
+
h.aset(ruby.to_symbol("limit_exceeded"), limit)?;
|
|
255
|
+
Ok(())
|
|
256
|
+
}
|
|
257
|
+
|
|
90
258
|
fn build_request(
|
|
91
259
|
ruby: &Ruby,
|
|
92
260
|
command: String,
|
|
@@ -143,6 +311,8 @@ fn build_request(
|
|
|
143
311
|
req.containment = match backend.name()?.as_ref() {
|
|
144
312
|
"auto" => Containment::Process,
|
|
145
313
|
"bubblewrap" => Containment::Bubblewrap,
|
|
314
|
+
// The typed request has no Landlock; `request_json` names it.
|
|
315
|
+
"landlock" => Containment::Process,
|
|
146
316
|
"seatbelt" => Containment::Seatbelt(SeatbeltConfig::default()),
|
|
147
317
|
"process_container" => {
|
|
148
318
|
Containment::ProcessContainer(ProcessContainerConfig::default())
|
|
@@ -153,19 +323,62 @@ fn build_request(
|
|
|
153
323
|
Ok((req, egress_proxy))
|
|
154
324
|
}
|
|
155
325
|
|
|
156
|
-
|
|
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> {
|
|
157
330
|
let h = ruby.hash_new();
|
|
158
|
-
match outcome {
|
|
159
|
-
WaitResult::Exited(code) =>
|
|
160
|
-
|
|
161
|
-
|
|
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)?;
|
|
162
352
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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();
|
|
166
379
|
}
|
|
167
380
|
}
|
|
168
|
-
Ok(
|
|
381
|
+
Ok(((outcome, warnings, metadata), drained(stdout)?, drained(stderr)?))
|
|
169
382
|
}
|
|
170
383
|
|
|
171
384
|
fn snake_case(name: &str) -> String {
|
|
@@ -202,18 +415,21 @@ fn backends(ruby: &Ruby) -> Result<RArray, Error> {
|
|
|
202
415
|
}
|
|
203
416
|
|
|
204
417
|
fn run(ruby: &Ruby, command: String, opts: RHash) -> Result<RHash, Error> {
|
|
205
|
-
let (req, mut egress_proxy) =
|
|
206
|
-
let result = without_gvl(move ||
|
|
418
|
+
let (req, mut egress_proxy) = prepare(ruby, command, opts)?;
|
|
419
|
+
let result = without_gvl(move || req.spawn().map(capture));
|
|
207
420
|
let blocked = egress_proxy.as_mut().map(|p| {
|
|
208
421
|
p.stop();
|
|
209
422
|
p.blocked()
|
|
210
423
|
});
|
|
211
|
-
let
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
h
|
|
215
|
-
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)?;
|
|
216
431
|
h.aset(ruby.to_symbol("blocked"), blocked.unwrap_or_default())?;
|
|
432
|
+
usage_into(ruby, h, metadata.as_ref())?;
|
|
217
433
|
Ok(h)
|
|
218
434
|
}
|
|
219
435
|
|
|
@@ -233,10 +449,9 @@ struct Process {
|
|
|
233
449
|
}
|
|
234
450
|
|
|
235
451
|
fn spawn(ruby: &Ruby, command: String, opts: RHash) -> Result<Process, Error> {
|
|
236
|
-
let
|
|
237
|
-
let
|
|
238
|
-
let mut child = without_gvl(move ||
|
|
239
|
-
.map_err(|e| mxc_error(ruby, e))?;
|
|
452
|
+
let timeout = opt::<u32>(ruby, opts, "timeout_ms")?.map(|ms| Duration::from_millis(ms.into()));
|
|
453
|
+
let (req, egress_proxy) = prepare(ruby, command, opts)?;
|
|
454
|
+
let mut child = without_gvl(move || req.spawn()).map_err(|e| mxc_error(ruby, e))?;
|
|
240
455
|
Ok(Process {
|
|
241
456
|
pid: child.id(),
|
|
242
457
|
deadline: timeout.map(|t| Instant::now() + t),
|
|
@@ -278,6 +493,21 @@ impl Process {
|
|
|
278
493
|
self.pid
|
|
279
494
|
}
|
|
280
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
|
+
|
|
281
511
|
fn read_stdout(ruby: &Ruby, rb_self: &Self, max: usize) -> Result<Option<RString>, Error> {
|
|
282
512
|
read_from(ruby, &rb_self.stdout, max)
|
|
283
513
|
}
|
|
@@ -315,7 +545,7 @@ impl Process {
|
|
|
315
545
|
|
|
316
546
|
// MxcProcess::kill and ::wait both need the handle exclusively, so waiting
|
|
317
547
|
// polls and releases the lock between polls to let kill in.
|
|
318
|
-
fn await_exit(&self) -> std::io::Result<
|
|
548
|
+
fn await_exit(&self) -> std::io::Result<Outcome> {
|
|
319
549
|
let mut nap = Duration::from_millis(1);
|
|
320
550
|
loop {
|
|
321
551
|
{
|
|
@@ -324,11 +554,12 @@ impl Process {
|
|
|
324
554
|
.as_mut()
|
|
325
555
|
.ok_or_else(|| std::io::Error::other("the process was already waited for"))?;
|
|
326
556
|
let expired = self.deadline.is_some_and(|d| Instant::now() >= d);
|
|
327
|
-
if expired || child.try_wait()
|
|
328
|
-
let outcome = child.wait()
|
|
557
|
+
if expired || !matches!(child.try_wait(), Ok(None)) {
|
|
558
|
+
let outcome = child.wait();
|
|
329
559
|
let warnings = child.warnings();
|
|
560
|
+
let metadata = child.output_metadata().cloned();
|
|
330
561
|
guard.take();
|
|
331
|
-
return Ok((outcome, warnings));
|
|
562
|
+
return Ok((outcome, warnings, metadata));
|
|
332
563
|
}
|
|
333
564
|
}
|
|
334
565
|
let mut sleep = nap;
|
|
@@ -349,8 +580,11 @@ impl Process {
|
|
|
349
580
|
}
|
|
350
581
|
None => Vec::new(),
|
|
351
582
|
};
|
|
352
|
-
let (outcome, warnings) = res.map_err(|e| io_error(ruby, e))?;
|
|
353
|
-
if
|
|
583
|
+
let (outcome, warnings, metadata) = res.map_err(|e| io_error(ruby, e))?;
|
|
584
|
+
if outcome.is_err() {
|
|
585
|
+
without_gvl(|| drain_until(|| rb_self.drained()));
|
|
586
|
+
}
|
|
587
|
+
if !matches!(outcome, Ok(WaitResult::Exited(_))) {
|
|
354
588
|
if let Some(c) = &rb_self.stdout_closer {
|
|
355
589
|
c.close();
|
|
356
590
|
}
|
|
@@ -361,6 +595,7 @@ impl Process {
|
|
|
361
595
|
let h = outcome_hash(ruby, outcome)?;
|
|
362
596
|
h.aset(ruby.to_symbol("warnings"), warnings)?;
|
|
363
597
|
h.aset(ruby.to_symbol("blocked"), blocked)?;
|
|
598
|
+
usage_into(ruby, h, metadata.as_ref())?;
|
|
364
599
|
Ok(h)
|
|
365
600
|
}
|
|
366
601
|
}
|
|
@@ -374,6 +609,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
374
609
|
native.define_singleton_method("spawn", function!(spawn, 2))?;
|
|
375
610
|
let process = native.define_class("Process", ruby.class_object())?;
|
|
376
611
|
process.define_method("pid", method!(Process::pid, 0))?;
|
|
612
|
+
process.define_method("warnings", method!(Process::warnings, 0))?;
|
|
377
613
|
process.define_method("read_stdout", method!(Process::read_stdout, 1))?;
|
|
378
614
|
process.define_method("read_stderr", method!(Process::read_stderr, 1))?;
|
|
379
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
|
@@ -12,15 +12,46 @@ module MXC
|
|
|
12
12
|
'a sandbox to a proxy only by also accepting inbound connections to it'
|
|
13
13
|
WINDOWS_DENIED = 'Leave the paths out of readable and writable instead, or run on a Windows build where ' \
|
|
14
14
|
'MXC.supports?(:filesystem_denied_paths) is true'
|
|
15
|
+
MACOS_MEMORY = 'Leave memory out of the limits on macOS, which rejects memory rlimits. ' \
|
|
16
|
+
'Linux and Windows support it'
|
|
17
|
+
PROCESS_LIMIT = 'Leave processes out of the limits. Only Windows can count the processes of one sandbox; ' \
|
|
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"
|
|
15
22
|
|
|
16
23
|
module_function
|
|
17
24
|
|
|
18
|
-
def check!(network:, denied: [])
|
|
19
|
-
error = problem(network:, denied:)
|
|
25
|
+
def check!(network:, denied: [], resources: nil, backend: :auto)
|
|
26
|
+
error = problem(network:, denied:, resources:, backend:)
|
|
20
27
|
raise error if error
|
|
21
28
|
end
|
|
22
29
|
|
|
23
|
-
def problem(network:, denied: [], path: ENV.fetch('PATH', ''), tun: 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)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def resource_problem(resources)
|
|
43
|
+
return if resources.nil? || windows?
|
|
44
|
+
|
|
45
|
+
if resources[:max_processes]
|
|
46
|
+
UnsupportedError.new("Process limits are not supported on #{linux? ? 'Linux' : 'macOS'}",
|
|
47
|
+
code: :backend_unavailable, remediation: PROCESS_LIMIT)
|
|
48
|
+
elsif resources[:memory_mb] && !linux?
|
|
49
|
+
UnsupportedError.new('Memory limits are not supported on macOS',
|
|
50
|
+
code: :backend_unavailable, remediation: MACOS_MEMORY)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def host_problem(network, denied, path, tun)
|
|
24
55
|
return windows_problem(network, denied) if windows?
|
|
25
56
|
return unless network && linux?
|
|
26
57
|
|
data/lib/mxc/result.rb
CHANGED
|
@@ -25,7 +25,18 @@ module MXC
|
|
|
25
25
|
# Returns the backend that ran the command, such as <tt>:bubblewrap</tt>.
|
|
26
26
|
attr_reader :backend
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
# Returns the CPU time the command used, user and system, in seconds as a Float. It
|
|
29
|
+
# includes every process the command started and waited for. Returns nil unless the
|
|
30
|
+
# sandbox has limits or a priority.
|
|
31
|
+
attr_reader :cpu_time
|
|
32
|
+
|
|
33
|
+
# Returns the limit the command ran into: <tt>:cpu_time</tt>, <tt>:memory</tt>,
|
|
34
|
+
# <tt>:processes</tt>, or nil. Linux and macOS report <tt>:cpu_time</tt> only, because a
|
|
35
|
+
# program that cannot allocate memory decides itself how to fail.
|
|
36
|
+
attr_reader :limit_exceeded
|
|
37
|
+
|
|
38
|
+
def initialize(stdout:, stderr:, exit_code:, timed_out:, warnings:, blocked:, backend:, # :nodoc:
|
|
39
|
+
cpu_time: nil, limit_exceeded: nil)
|
|
29
40
|
@stdout = text(stdout)
|
|
30
41
|
@stderr = text(stderr)
|
|
31
42
|
@exit_code = exit_code
|
|
@@ -33,6 +44,8 @@ module MXC
|
|
|
33
44
|
@warnings = warnings.freeze
|
|
34
45
|
@blocked = blocked.freeze
|
|
35
46
|
@backend = backend
|
|
47
|
+
@cpu_time = cpu_time
|
|
48
|
+
@limit_exceeded = limit_exceeded
|
|
36
49
|
end
|
|
37
50
|
|
|
38
51
|
# Returns true when the command exited with status 0 before its timeout.
|
|
@@ -47,11 +60,13 @@ module MXC
|
|
|
47
60
|
|
|
48
61
|
# Returns the result as a Hash.
|
|
49
62
|
def to_h
|
|
50
|
-
{ stdout:, stderr:, exit_code:, timed_out: timed_out?, warnings:, blocked:, backend
|
|
63
|
+
{ stdout:, stderr:, exit_code:, timed_out: timed_out?, warnings:, blocked:, backend:, cpu_time:,
|
|
64
|
+
limit_exceeded: }
|
|
51
65
|
end
|
|
52
66
|
|
|
53
67
|
def inspect # :nodoc:
|
|
54
|
-
"
|
|
68
|
+
usage = " cpu_time=#{cpu_time.round(3)} limit_exceeded=#{limit_exceeded.inspect}" if cpu_time
|
|
69
|
+
"#<MXC::Result exit_code=#{exit_code.inspect} timed_out=#{timed_out?}#{usage} backend=#{backend.inspect}>"
|
|
55
70
|
end
|
|
56
71
|
|
|
57
72
|
private
|
data/lib/mxc/sandbox.rb
CHANGED
|
@@ -22,19 +22,26 @@ module MXC
|
|
|
22
22
|
# writable "tmp/skills"
|
|
23
23
|
# network false
|
|
24
24
|
# timeout 30
|
|
25
|
+
# limits cpu_time: 60, memory: 2 * 1024**3
|
|
26
|
+
# priority :low
|
|
25
27
|
# end
|
|
26
28
|
#
|
|
27
29
|
# SkillSandbox.new.run("python3 app/skills/build.py")
|
|
28
30
|
class Sandbox
|
|
29
|
-
BACKENDS = %i[auto bubblewrap seatbelt process_container].freeze # :nodoc:
|
|
30
|
-
|
|
31
|
+
BACKENDS = %i[auto bubblewrap landlock seatbelt process_container].freeze # :nodoc:
|
|
32
|
+
LANDLOCK_FALLBACK = /runs on Landlock instead/ # :nodoc:
|
|
33
|
+
OPTIONS = %i[readable writable denied network env directory timeout backend limits priority].freeze # :nodoc:
|
|
31
34
|
HOST_RESOLVER_SOCKETS = %w[/run/systemd/resolve].freeze # :nodoc:
|
|
32
35
|
WINDOWS_ENV = %w[SYSTEMROOT WINDIR SYSTEMDRIVE COMSPEC PATHEXT PATH LOCALAPPDATA TEMP TMP
|
|
33
36
|
PROCESSOR_ARCHITECTURE NUMBER_OF_PROCESSORS OS].freeze # :nodoc:
|
|
34
37
|
BATCH_FILE = /\.(bat|cmd)\z/i # :nodoc:
|
|
35
38
|
MAX_TIMEOUT_MS = (2**32) - 1 # :nodoc:
|
|
39
|
+
LIMITS = %i[cpu_time memory processes].freeze # :nodoc:
|
|
40
|
+
PRIORITIES = { normal: nil, low: 'low', lowest: 'idle' }.freeze # :nodoc:
|
|
41
|
+
MEGABYTE = 1024 * 1024 # :nodoc:
|
|
36
42
|
DEFAULTS = {
|
|
37
|
-
readable: [], writable: [], denied: [], env: {}, network: false, directory: nil, timeout: nil, backend: nil
|
|
43
|
+
readable: [], writable: [], denied: [], env: {}, network: false, directory: nil, timeout: nil, backend: nil,
|
|
44
|
+
limits: {}, priority: nil
|
|
38
45
|
}.freeze # :nodoc:
|
|
39
46
|
|
|
40
47
|
class << self
|
|
@@ -62,6 +69,12 @@ module MXC
|
|
|
62
69
|
# Sets every instance's backend.
|
|
63
70
|
def backend(name) = configure { |sandbox| sandbox.with_backend(name) }
|
|
64
71
|
|
|
72
|
+
# Sets every instance's resource limits. Takes the same arguments as #with_limits.
|
|
73
|
+
def limits(...) = configure { |sandbox| sandbox.with_limits(...) }
|
|
74
|
+
|
|
75
|
+
# Sets every instance's scheduling priority: <tt>:normal</tt>, <tt>:low</tt>, or <tt>:lowest</tt>.
|
|
76
|
+
def priority(level) = configure { |sandbox| sandbox.with_priority(level) }
|
|
77
|
+
|
|
65
78
|
def defaults # :nodoc:
|
|
66
79
|
@defaults ||= allocate.tap { |sandbox| sandbox.send(:inherit, self == Sandbox ? nil : superclass.defaults) }
|
|
67
80
|
end
|
|
@@ -95,9 +108,11 @@ module MXC
|
|
|
95
108
|
|
|
96
109
|
# Returns a new sandbox with the class's defaults, then applies +options+ through
|
|
97
110
|
# the matching setters: <tt>readable:</tt>, <tt>writable:</tt>, <tt>denied:</tt>,
|
|
98
|
-
# <tt>network:</tt>, <tt>env:</tt>, <tt>directory:</tt>, <tt>timeout:</tt
|
|
111
|
+
# <tt>network:</tt>, <tt>env:</tt>, <tt>directory:</tt>, <tt>timeout:</tt>, <tt>backend:</tt>,
|
|
112
|
+
# <tt>limits:</tt> and <tt>priority:</tt>.
|
|
99
113
|
#
|
|
100
114
|
# MXC::Sandbox.new(readable: "app/skills", network: { allow: ["pypi.org"] }, timeout: 30)
|
|
115
|
+
# MXC::Sandbox.new(limits: { cpu_time: 60, memory: 2 * 1024**3 }, priority: :low)
|
|
101
116
|
def initialize(**options)
|
|
102
117
|
unknown = options.keys - OPTIONS
|
|
103
118
|
raise ArgumentError, "unknown option#{'s' if unknown.size > 1}: #{unknown.join(', ')}" if unknown.any?
|
|
@@ -135,6 +150,13 @@ module MXC
|
|
|
135
150
|
# Returns the backend, falling back to MXC.config.backend.
|
|
136
151
|
def backend = @settings[:backend] || MXC.config.backend
|
|
137
152
|
|
|
153
|
+
# Returns the resource limits, such as <tt>{ cpu_time: 60, memory: 2147483648 }</tt>.
|
|
154
|
+
# A limit that is not set is absent.
|
|
155
|
+
def limits = @settings[:limits].dup.freeze
|
|
156
|
+
|
|
157
|
+
# Returns the scheduling priority: <tt>:normal</tt>, <tt>:low</tt>, or <tt>:lowest</tt>.
|
|
158
|
+
def priority = @settings[:priority] || :normal
|
|
159
|
+
|
|
138
160
|
# Adds paths the command can read. Accepts Strings and Pathnames; relative paths
|
|
139
161
|
# are expanded from the current directory when the command runs.
|
|
140
162
|
def with_readable(*paths)
|
|
@@ -197,13 +219,53 @@ module MXC
|
|
|
197
219
|
end
|
|
198
220
|
|
|
199
221
|
# Sets the backend: <tt>:auto</tt> (Bubblewrap on Linux, Seatbelt on macOS,
|
|
200
|
-
# ProcessContainer on Windows), <tt>:bubblewrap</tt>, <tt>:seatbelt</tt>,
|
|
201
|
-
# <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.
|
|
202
228
|
def with_backend(name)
|
|
203
229
|
@settings[:backend] = name && self.class.backend_name(name)
|
|
204
230
|
self
|
|
205
231
|
end
|
|
206
232
|
|
|
233
|
+
# Sets resource limits. Each process the command starts gets its own CPU time and memory.
|
|
234
|
+
#
|
|
235
|
+
# <tt>cpu_time:</tt> is the CPU time each process may use, in seconds. It accepts
|
|
236
|
+
# anything that responds to +to_f+, such as <tt>90</tt> or <tt>2.minutes</tt>, and is
|
|
237
|
+
# rounded up to whole seconds on Linux and macOS. A process that uses it up is stopped.
|
|
238
|
+
#
|
|
239
|
+
# <tt>memory:</tt> is the memory each process may allocate, in bytes. It accepts
|
|
240
|
+
# anything that responds to +to_i+, such as <tt>2 * 1024**3</tt> or
|
|
241
|
+
# <tt>2.gigabytes</tt>, and is rounded up to whole megabytes. Allocations beyond it
|
|
242
|
+
# fail. Not available on macOS.
|
|
243
|
+
#
|
|
244
|
+
# <tt>processes:</tt> is how many processes the command may run at the same time.
|
|
245
|
+
# Windows only.
|
|
246
|
+
#
|
|
247
|
+
# A limit given as +nil+ is removed, and <tt>with_limits(nil)</tt> removes them all.
|
|
248
|
+
# Raises ArgumentError for an unknown limit or a value that is not positive.
|
|
249
|
+
#
|
|
250
|
+
# sandbox.with_limits(cpu_time: 2.minutes, memory: 2.gigabytes)
|
|
251
|
+
def with_limits(reset = (keep = true), **limits)
|
|
252
|
+
unless keep
|
|
253
|
+
raise ArgumentError, "pass limits as keywords, or nil to remove them, got #{reset.inspect}" unless reset.nil?
|
|
254
|
+
|
|
255
|
+
@settings[:limits].clear
|
|
256
|
+
end
|
|
257
|
+
limits.each { |name, value| set_limit(name, value) }
|
|
258
|
+
self
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Sets the scheduling priority of every process the command starts: <tt>:normal</tt>
|
|
262
|
+
# (the default), <tt>:low</tt> (nice 10 on Linux and macOS, below normal on
|
|
263
|
+
# Windows), or <tt>:lowest</tt> (nice 19, or idle on Windows).
|
|
264
|
+
def with_priority(level)
|
|
265
|
+
@settings[:priority] = level && priority_name(level)
|
|
266
|
+
self
|
|
267
|
+
end
|
|
268
|
+
|
|
207
269
|
# Runs +command+ and returns its Result once it finishes. Like Kernel#system, one
|
|
208
270
|
# String runs through the platform shell: <tt>sh -c</tt>, or <tt>cmd.exe /d /s /c</tt>
|
|
209
271
|
# on Windows. Two or more Strings are a program and its arguments, and each argument
|
|
@@ -222,7 +284,7 @@ module MXC
|
|
|
222
284
|
return stream(*command, &) if block_given?
|
|
223
285
|
|
|
224
286
|
outcome = StartupFailure.check!(Native.run(command_line(command), request))
|
|
225
|
-
Result.new(**outcome, backend: resolved_backend)
|
|
287
|
+
Result.new(**outcome, backend: resolved_backend(outcome[:warnings]))
|
|
226
288
|
end
|
|
227
289
|
|
|
228
290
|
# Starts +command+ and returns its Process without waiting for it. Takes the command
|
|
@@ -230,7 +292,8 @@ module MXC
|
|
|
230
292
|
#
|
|
231
293
|
# sandbox.spawn("python3", "-i", "-q")
|
|
232
294
|
def spawn(*command)
|
|
233
|
-
|
|
295
|
+
native = Native.spawn(command_line(command), request)
|
|
296
|
+
Process.new(native, backend: resolved_backend(native.warnings))
|
|
234
297
|
end
|
|
235
298
|
|
|
236
299
|
# Returns true when this host can run this sandbox, including its network policy.
|
|
@@ -241,15 +304,18 @@ module MXC
|
|
|
241
304
|
# Returns the Availability of this sandbox on this host, with the reason and
|
|
242
305
|
# remediation when it cannot run.
|
|
243
306
|
def availability
|
|
244
|
-
problem = Preflight.problem(network:, denied:)
|
|
245
|
-
|
|
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
|
|
246
311
|
|
|
247
312
|
Availability.new(backend:, supported: false, reason: problem.message, remediation: problem.remediation)
|
|
248
313
|
end
|
|
249
314
|
|
|
250
315
|
def inspect # :nodoc:
|
|
316
|
+
resources = " limits=#{limits.inspect} priority=#{priority.inspect}" if self.resources
|
|
251
317
|
"#<#{self.class.name || 'MXC::Sandbox'} backend=#{backend.inspect} network=#{network.inspect} " \
|
|
252
|
-
"readable=#{readable.inspect} writable=#{writable.inspect} timeout=#{timeout.inspect}>"
|
|
318
|
+
"readable=#{readable.inspect} writable=#{writable.inspect} timeout=#{timeout.inspect}#{resources}>"
|
|
253
319
|
end
|
|
254
320
|
|
|
255
321
|
protected
|
|
@@ -266,10 +332,42 @@ module MXC
|
|
|
266
332
|
case name
|
|
267
333
|
when :readable, :writable, :denied then public_send(:"with_#{name}", *Array(value))
|
|
268
334
|
when :network then value.is_a?(Hash) ? with_network(**value) : with_network(value)
|
|
335
|
+
when :limits then value.nil? ? with_limits(nil) : with_limits(**value)
|
|
269
336
|
else public_send(:"with_#{name}", value)
|
|
270
337
|
end
|
|
271
338
|
end
|
|
272
339
|
|
|
340
|
+
def set_limit(name, value)
|
|
341
|
+
unless LIMITS.include?(name)
|
|
342
|
+
raise ArgumentError, "unknown limit #{name.inspect}, expected one of #{LIMITS.map(&:inspect).join(', ')}"
|
|
343
|
+
end
|
|
344
|
+
return @settings[:limits].delete(name) if value.nil?
|
|
345
|
+
|
|
346
|
+
@settings[:limits][name] = positive_limit(name, value)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def positive_limit(name, value)
|
|
350
|
+
amount = limit_amount(name, value)
|
|
351
|
+
raise ArgumentError, "#{name} must be a positive number, got #{value.inspect}" unless amount&.positive?
|
|
352
|
+
|
|
353
|
+
value
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def limit_amount(name, value)
|
|
357
|
+
return (value if value.is_a?(Integer)) if name == :processes
|
|
358
|
+
|
|
359
|
+
conversion = name == :cpu_time ? :to_f : :to_i
|
|
360
|
+
value.public_send(conversion) if value.respond_to?(conversion)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def priority_name(level)
|
|
364
|
+
level = level.to_sym
|
|
365
|
+
return level if PRIORITIES.key?(level)
|
|
366
|
+
|
|
367
|
+
raise ArgumentError,
|
|
368
|
+
"unknown priority #{level.inspect}, expected one of #{PRIORITIES.keys.map(&:inspect).join(', ')}"
|
|
369
|
+
end
|
|
370
|
+
|
|
273
371
|
def add_paths(name, paths)
|
|
274
372
|
list = @settings[name]
|
|
275
373
|
paths == [nil] ? list.clear : list.concat(paths.flatten.map { |path| File.path(path) })
|
|
@@ -314,15 +412,16 @@ module MXC
|
|
|
314
412
|
arguments.map { |argument| WindowsArguments.quote(argument) }.join(' ')
|
|
315
413
|
end
|
|
316
414
|
|
|
317
|
-
def resolved_backend
|
|
415
|
+
def resolved_backend(warnings)
|
|
318
416
|
return backend unless backend == :auto
|
|
319
417
|
return :process_container if Preflight.windows?
|
|
418
|
+
return :seatbelt unless Preflight.linux?
|
|
320
419
|
|
|
321
|
-
|
|
420
|
+
warnings.any?(LANDLOCK_FALLBACK) ? :landlock : :bubblewrap
|
|
322
421
|
end
|
|
323
422
|
|
|
324
423
|
def request
|
|
325
|
-
Preflight.check!(network:, denied:)
|
|
424
|
+
Preflight.check!(network:, denied:, resources:, backend:)
|
|
326
425
|
{
|
|
327
426
|
readonly: expand(readable),
|
|
328
427
|
readwrite: expand(writable),
|
|
@@ -332,10 +431,28 @@ module MXC
|
|
|
332
431
|
timeout_ms: timeout && [(timeout.to_f * 1000).ceil, MAX_TIMEOUT_MS].min,
|
|
333
432
|
backend:,
|
|
334
433
|
network: network == true,
|
|
335
|
-
allow: network.is_a?(Hash) ? network[:allow] : nil
|
|
434
|
+
allow: network.is_a?(Hash) ? network[:allow] : nil,
|
|
435
|
+
resources:
|
|
336
436
|
}
|
|
337
437
|
end
|
|
338
438
|
|
|
439
|
+
protected
|
|
440
|
+
|
|
441
|
+
# The resource limits as MXC takes them, or nil when there are none.
|
|
442
|
+
def resources
|
|
443
|
+
limits = @settings[:limits]
|
|
444
|
+
return if limits.empty? && priority == :normal
|
|
445
|
+
|
|
446
|
+
{
|
|
447
|
+
cpu_time_ms: limits[:cpu_time] && (limits[:cpu_time].to_f * 1000).ceil,
|
|
448
|
+
memory_mb: limits[:memory] && -(-limits[:memory].to_i / MEGABYTE),
|
|
449
|
+
max_processes: limits[:processes],
|
|
450
|
+
priority: PRIORITIES.fetch(priority)
|
|
451
|
+
}.compact
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
private
|
|
455
|
+
|
|
339
456
|
def expand(paths)
|
|
340
457
|
paths.map { |path| native_path(File.expand_path(path)) }
|
|
341
458
|
end
|
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
|
@@ -19,6 +19,11 @@ require 'mxc/sandbox'
|
|
|
19
19
|
# result = MXC.run("python3 build.py", readable: "app/skills", writable: "tmp/out", timeout: 30)
|
|
20
20
|
# result.stdout
|
|
21
21
|
module MXC
|
|
22
|
+
RESOURCE_CAPABILITIES = {
|
|
23
|
+
cpu_time_limit: { cpu_time_ms: 1 }, memory_limit: { memory_mb: 1 },
|
|
24
|
+
process_limit: { max_processes: 1 }, priority: { priority: 'low' }
|
|
25
|
+
}.freeze # :nodoc:
|
|
26
|
+
|
|
22
27
|
@availability = {}
|
|
23
28
|
@availability_lock = Mutex.new
|
|
24
29
|
|
|
@@ -82,22 +87,33 @@ module MXC
|
|
|
82
87
|
Native.backend_attributes.map { |attributes| Backend.new(**attributes) }
|
|
83
88
|
end
|
|
84
89
|
|
|
85
|
-
# 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>.
|
|
86
92
|
#
|
|
87
93
|
# +capability+ is <tt>:network</tt> for unrestricted network access,
|
|
88
|
-
# <tt>:network_policy</tt> for host allow-lists,
|
|
94
|
+
# <tt>:network_policy</tt> for host allow-lists, <tt>:cpu_time_limit</tt>,
|
|
95
|
+
# <tt>:memory_limit</tt>, <tt>:process_limit</tt> and <tt>:priority</tt> for the
|
|
96
|
+
# matching Sandbox#with_limits and Sandbox#with_priority settings, or any capability a
|
|
97
|
+
# Backend reports.
|
|
98
|
+
#
|
|
99
|
+
# MXC.supports?(:memory_limit) # => false on macOS
|
|
89
100
|
def supports?(capability)
|
|
90
101
|
return false unless supported?
|
|
91
102
|
|
|
92
103
|
case capability
|
|
93
|
-
when :network then Preflight.problem(network: true).nil?
|
|
94
|
-
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?
|
|
106
|
+
when *RESOURCE_CAPABILITIES.keys then Preflight.resource_problem(RESOURCE_CAPABILITIES[capability]).nil?
|
|
95
107
|
else configured_backends.any? { |backend| backend.supports?(capability) }
|
|
96
108
|
end
|
|
97
109
|
end
|
|
98
110
|
|
|
99
111
|
private
|
|
100
112
|
|
|
113
|
+
def runs_on
|
|
114
|
+
availability.runs_on
|
|
115
|
+
end
|
|
116
|
+
|
|
101
117
|
def proxy_enforcement?
|
|
102
118
|
!Preflight.linux? || configured_backends.any? { |backend| backend.supports?(:proxy_enforcement) }
|
|
103
119
|
end
|