mxc 0.1.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 +7 -0
- data/Cargo.lock +1500 -0
- data/Cargo.toml +6 -0
- data/LICENSE +21 -0
- data/NOTICE +34 -0
- data/README.md +140 -0
- data/ext/mxc/Cargo.toml +18 -0
- data/ext/mxc/build.rs +28 -0
- data/ext/mxc/extconf.rb +24 -0
- data/ext/mxc/src/lib.rs +384 -0
- data/ext/mxc/src/proxy.rs +480 -0
- data/lib/mxc/availability.rb +45 -0
- data/lib/mxc/backend.rb +36 -0
- data/lib/mxc/chunk.rb +33 -0
- data/lib/mxc/configuration.rb +28 -0
- data/lib/mxc/error.rb +36 -0
- data/lib/mxc/native.rb +29 -0
- data/lib/mxc/preflight.rb +82 -0
- data/lib/mxc/process.rb +100 -0
- data/lib/mxc/result.rb +63 -0
- data/lib/mxc/sandbox.rb +366 -0
- data/lib/mxc/startup_failure.rb +42 -0
- data/lib/mxc/version.rb +6 -0
- data/lib/mxc/windows_arguments.rb +18 -0
- data/lib/mxc.rb +109 -0
- metadata +87 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
use std::io::{self, Read, Write};
|
|
2
|
+
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, Shutdown, SocketAddr, TcpListener, TcpStream, ToSocketAddrs};
|
|
3
|
+
use std::sync::atomic::{AtomicBool, Ordering};
|
|
4
|
+
use std::sync::{Arc, Mutex};
|
|
5
|
+
use std::thread::{self, JoinHandle};
|
|
6
|
+
use std::time::Duration;
|
|
7
|
+
|
|
8
|
+
const MAX_HEADER: usize = 16 * 1024;
|
|
9
|
+
const HEADER_TIMEOUT: Duration = Duration::from_secs(30);
|
|
10
|
+
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
|
11
|
+
const DEFAULT_PORTS: [u16; 2] = [80, 443];
|
|
12
|
+
|
|
13
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
14
|
+
enum HostPattern {
|
|
15
|
+
Exact(String),
|
|
16
|
+
Suffix(String),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
20
|
+
struct Rule {
|
|
21
|
+
host: HostPattern,
|
|
22
|
+
ports: Vec<u16>,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#[derive(Debug, Clone, Default)]
|
|
26
|
+
pub struct Allowlist {
|
|
27
|
+
rules: Vec<Rule>,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
fn normalize_host(host: &str) -> String {
|
|
31
|
+
host.trim()
|
|
32
|
+
.trim_start_matches('[')
|
|
33
|
+
.trim_end_matches(']')
|
|
34
|
+
.trim_end_matches('.')
|
|
35
|
+
.to_ascii_lowercase()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
fn split_host_port(entry: &str) -> (&str, Option<&str>) {
|
|
39
|
+
if let Some(rest) = entry.strip_prefix('[') {
|
|
40
|
+
if let Some(end) = rest.find(']') {
|
|
41
|
+
let port = rest[end + 1..].strip_prefix(':');
|
|
42
|
+
return (&rest[..end], port);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
match entry.rsplit_once(':') {
|
|
46
|
+
Some((host, port)) if !host.contains(':') => (host, Some(port)),
|
|
47
|
+
_ => (entry, None),
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
impl Allowlist {
|
|
52
|
+
pub fn parse(entries: &[String]) -> Result<Self, String> {
|
|
53
|
+
let mut rules = Vec::new();
|
|
54
|
+
for raw in entries {
|
|
55
|
+
let (host, port) = split_host_port(raw.trim());
|
|
56
|
+
let host = normalize_host(host);
|
|
57
|
+
if host.is_empty() || host.contains('/') || host.contains(char::is_whitespace) {
|
|
58
|
+
return Err(format!("invalid allow entry {raw:?}"));
|
|
59
|
+
}
|
|
60
|
+
let ports = match port {
|
|
61
|
+
Some(p) => vec![p
|
|
62
|
+
.parse::<u16>()
|
|
63
|
+
.ok()
|
|
64
|
+
.filter(|p| *p != 0)
|
|
65
|
+
.ok_or_else(|| format!("invalid port in allow entry {raw:?}"))?],
|
|
66
|
+
None => DEFAULT_PORTS.to_vec(),
|
|
67
|
+
};
|
|
68
|
+
let host = match host.strip_prefix("*.") {
|
|
69
|
+
Some(suffix) if !suffix.is_empty() && !suffix.contains('*') => {
|
|
70
|
+
HostPattern::Suffix(suffix.to_string())
|
|
71
|
+
}
|
|
72
|
+
_ if host.contains('*') => {
|
|
73
|
+
return Err(format!(
|
|
74
|
+
"invalid allow entry {raw:?}: only a leading \"*.\" wildcard is supported"
|
|
75
|
+
))
|
|
76
|
+
}
|
|
77
|
+
_ => HostPattern::Exact(host),
|
|
78
|
+
};
|
|
79
|
+
rules.push(Rule { host, ports });
|
|
80
|
+
}
|
|
81
|
+
Ok(Self { rules })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
pub fn permits(&self, host: &str, port: u16) -> bool {
|
|
85
|
+
let host = normalize_host(host);
|
|
86
|
+
self.rules.iter().any(|rule| {
|
|
87
|
+
rule.ports.contains(&port)
|
|
88
|
+
&& match &rule.host {
|
|
89
|
+
HostPattern::Exact(h) => *h == host,
|
|
90
|
+
HostPattern::Suffix(s) => {
|
|
91
|
+
host.len() > s.len() + 1
|
|
92
|
+
&& host.ends_with(s.as_str())
|
|
93
|
+
&& host.as_bytes()[host.len() - s.len() - 1] == b'.'
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
fn explicitly_lists_ip(&self, ip: &IpAddr) -> bool {
|
|
100
|
+
let literal = ip.to_string();
|
|
101
|
+
self.rules
|
|
102
|
+
.iter()
|
|
103
|
+
.any(|rule| matches!(&rule.host, HostPattern::Exact(h) if *h == literal))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
pub fn address_permitted(&self, ip: &IpAddr) -> bool {
|
|
107
|
+
is_public(ip) || self.explicitly_lists_ip(ip)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
pub fn is_public(ip: &IpAddr) -> bool {
|
|
112
|
+
match ip {
|
|
113
|
+
IpAddr::V4(v4) => is_public_v4(v4),
|
|
114
|
+
IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
|
|
115
|
+
Some(v4) => is_public_v4(&v4),
|
|
116
|
+
None => is_public_v6(v6),
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
fn is_public_v4(ip: &Ipv4Addr) -> bool {
|
|
122
|
+
let o = ip.octets();
|
|
123
|
+
!(ip.is_unspecified()
|
|
124
|
+
|| ip.is_loopback()
|
|
125
|
+
|| ip.is_private()
|
|
126
|
+
|| ip.is_link_local()
|
|
127
|
+
|| ip.is_broadcast()
|
|
128
|
+
|| ip.is_multicast()
|
|
129
|
+
|| ip.is_documentation()
|
|
130
|
+
|| o[0] == 0
|
|
131
|
+
|| (o[0] == 100 && (o[1] & 0xc0) == 64)
|
|
132
|
+
|| (o[0] == 192 && o[1] == 0 && o[2] == 0)
|
|
133
|
+
|| (o[0] == 198 && (o[1] & 0xfe) == 18)
|
|
134
|
+
|| o[0] >= 240)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
fn is_public_v6(ip: &Ipv6Addr) -> bool {
|
|
138
|
+
let s = ip.segments();
|
|
139
|
+
!(ip.is_unspecified()
|
|
140
|
+
|| ip.is_loopback()
|
|
141
|
+
|| ip.is_multicast()
|
|
142
|
+
|| (s[0] & 0xfe00) == 0xfc00
|
|
143
|
+
|| (s[0] & 0xffc0) == 0xfe80
|
|
144
|
+
|| (s[0] == 0x2001 && s[1] == 0x0db8)
|
|
145
|
+
|| (s[0] == 0x64 && s[1] == 0xff9b))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
struct Shared {
|
|
149
|
+
allowlist: Allowlist,
|
|
150
|
+
stopping: AtomicBool,
|
|
151
|
+
connections: Mutex<Vec<TcpStream>>,
|
|
152
|
+
blocked: Mutex<Vec<String>>,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
pub struct EgressProxy {
|
|
156
|
+
addr: SocketAddr,
|
|
157
|
+
shared: Arc<Shared>,
|
|
158
|
+
acceptor: Option<JoinHandle<()>>,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
impl EgressProxy {
|
|
162
|
+
pub fn start(allowlist: Allowlist) -> io::Result<Self> {
|
|
163
|
+
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
|
|
164
|
+
let addr = listener.local_addr()?;
|
|
165
|
+
let shared = Arc::new(Shared {
|
|
166
|
+
allowlist,
|
|
167
|
+
stopping: AtomicBool::new(false),
|
|
168
|
+
connections: Mutex::new(Vec::new()),
|
|
169
|
+
blocked: Mutex::new(Vec::new()),
|
|
170
|
+
});
|
|
171
|
+
let accept_shared = Arc::clone(&shared);
|
|
172
|
+
let acceptor = thread::Builder::new()
|
|
173
|
+
.name("mxc-egress-proxy".into())
|
|
174
|
+
.spawn(move || accept_loop(listener, accept_shared))?;
|
|
175
|
+
Ok(Self {
|
|
176
|
+
addr,
|
|
177
|
+
shared,
|
|
178
|
+
acceptor: Some(acceptor),
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
pub fn url(&self) -> String {
|
|
183
|
+
format!("http://{}", self.addr)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
pub fn blocked(&self) -> Vec<String> {
|
|
187
|
+
self.shared.blocked.lock().unwrap().clone()
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
pub fn stop(&mut self) {
|
|
191
|
+
if self.shared.stopping.swap(true, Ordering::SeqCst) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
let _ = TcpStream::connect_timeout(&self.addr, Duration::from_millis(200));
|
|
195
|
+
if let Some(handle) = self.acceptor.take() {
|
|
196
|
+
let _ = handle.join();
|
|
197
|
+
}
|
|
198
|
+
for conn in self.shared.connections.lock().unwrap().drain(..) {
|
|
199
|
+
let _ = conn.shutdown(Shutdown::Both);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
impl Drop for EgressProxy {
|
|
205
|
+
fn drop(&mut self) {
|
|
206
|
+
self.stop();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
fn accept_loop(listener: TcpListener, shared: Arc<Shared>) {
|
|
211
|
+
for stream in listener.incoming() {
|
|
212
|
+
if shared.stopping.load(Ordering::SeqCst) {
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
let Ok(stream) = stream else { continue };
|
|
216
|
+
let conn_shared = Arc::clone(&shared);
|
|
217
|
+
let _ = thread::Builder::new()
|
|
218
|
+
.name("mxc-egress-conn".into())
|
|
219
|
+
.spawn(move || {
|
|
220
|
+
let _ = handle(stream, &conn_shared);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
fn track(shared: &Shared, stream: &TcpStream) {
|
|
226
|
+
if let Ok(clone) = stream.try_clone() {
|
|
227
|
+
let mut conns = shared.connections.lock().unwrap();
|
|
228
|
+
conns.retain(|c| c.peer_addr().is_ok());
|
|
229
|
+
conns.push(clone);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
struct Head {
|
|
234
|
+
method: String,
|
|
235
|
+
target: String,
|
|
236
|
+
version: String,
|
|
237
|
+
headers: Vec<(String, String)>,
|
|
238
|
+
rest: Vec<u8>,
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
fn read_head(stream: &mut TcpStream) -> io::Result<Head> {
|
|
242
|
+
let mut buf = Vec::with_capacity(1024);
|
|
243
|
+
let mut chunk = [0u8; 2048];
|
|
244
|
+
let end = loop {
|
|
245
|
+
if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
|
|
246
|
+
break pos;
|
|
247
|
+
}
|
|
248
|
+
if buf.len() > MAX_HEADER {
|
|
249
|
+
return Err(io::Error::other("request header too large"));
|
|
250
|
+
}
|
|
251
|
+
let n = stream.read(&mut chunk)?;
|
|
252
|
+
if n == 0 {
|
|
253
|
+
return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "closed"));
|
|
254
|
+
}
|
|
255
|
+
buf.extend_from_slice(&chunk[..n]);
|
|
256
|
+
};
|
|
257
|
+
let text = std::str::from_utf8(&buf[..end]).map_err(|_| io::Error::other("bad header"))?;
|
|
258
|
+
let mut lines = text.split("\r\n");
|
|
259
|
+
let mut parts = lines.next().unwrap_or_default().split(' ');
|
|
260
|
+
let method = parts.next().unwrap_or_default().to_string();
|
|
261
|
+
let target = parts.next().unwrap_or_default().to_string();
|
|
262
|
+
let version = parts.next().unwrap_or("HTTP/1.1").to_string();
|
|
263
|
+
let headers = lines
|
|
264
|
+
.filter_map(|l| l.split_once(':'))
|
|
265
|
+
.map(|(k, v)| (k.trim().to_string(), v.trim().to_string()))
|
|
266
|
+
.collect();
|
|
267
|
+
Ok(Head {
|
|
268
|
+
method,
|
|
269
|
+
target,
|
|
270
|
+
version,
|
|
271
|
+
headers,
|
|
272
|
+
rest: buf[end + 4..].to_vec(),
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
fn respond(stream: &mut TcpStream, status: &str, body: &str) {
|
|
277
|
+
let _ = write!(
|
|
278
|
+
stream,
|
|
279
|
+
"HTTP/1.1 {status}\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
|
280
|
+
body.len()
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
fn parse_authority(authority: &str, default_port: u16) -> Option<(String, u16)> {
|
|
285
|
+
let (host, port) = split_host_port(authority);
|
|
286
|
+
let port = match port {
|
|
287
|
+
Some(p) => p.parse().ok()?,
|
|
288
|
+
None => default_port,
|
|
289
|
+
};
|
|
290
|
+
let host = normalize_host(host);
|
|
291
|
+
(!host.is_empty()).then_some((host, port))
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
fn parse_absolute_http(target: &str) -> Option<(String, u16, String)> {
|
|
295
|
+
let rest = target
|
|
296
|
+
.get(..7)
|
|
297
|
+
.filter(|s| s.eq_ignore_ascii_case("http://"))
|
|
298
|
+
.map(|_| &target[7..])?;
|
|
299
|
+
let (authority, path) = match rest.find('/') {
|
|
300
|
+
Some(i) => (&rest[..i], &rest[i..]),
|
|
301
|
+
None => (rest, "/"),
|
|
302
|
+
};
|
|
303
|
+
let authority = authority.rsplit('@').next()?;
|
|
304
|
+
let (host, port) = parse_authority(authority, 80)?;
|
|
305
|
+
Some((host, port, path.to_string()))
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
fn dial(shared: &Shared, host: &str, port: u16) -> Result<TcpStream, String> {
|
|
309
|
+
let addrs: Vec<SocketAddr> = (host, port)
|
|
310
|
+
.to_socket_addrs()
|
|
311
|
+
.map_err(|e| format!("cannot resolve {host}: {e}"))?
|
|
312
|
+
.collect();
|
|
313
|
+
let permitted: Vec<&SocketAddr> = addrs
|
|
314
|
+
.iter()
|
|
315
|
+
.filter(|a| shared.allowlist.address_permitted(&a.ip()))
|
|
316
|
+
.collect();
|
|
317
|
+
if permitted.is_empty() {
|
|
318
|
+
return Err(format!(
|
|
319
|
+
"{host} resolves only to non-public addresses ({})",
|
|
320
|
+
addrs.iter().map(|a| a.ip().to_string()).collect::<Vec<_>>().join(", ")
|
|
321
|
+
));
|
|
322
|
+
}
|
|
323
|
+
let mut last = String::new();
|
|
324
|
+
for addr in permitted {
|
|
325
|
+
match TcpStream::connect_timeout(addr, CONNECT_TIMEOUT) {
|
|
326
|
+
Ok(s) => return Ok(s),
|
|
327
|
+
Err(e) => last = format!("{addr}: {e}"),
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
Err(format!("cannot connect to {host}:{port}: {last}"))
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
fn deny(shared: &Shared, stream: &mut TcpStream, host: &str, port: u16, why: &str) {
|
|
334
|
+
shared.blocked.lock().unwrap().push(format!("{host}:{port}"));
|
|
335
|
+
respond(
|
|
336
|
+
stream,
|
|
337
|
+
"403 Forbidden",
|
|
338
|
+
&format!("mxc: egress to {host}:{port} is not allowed ({why})\n"),
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
fn handle(mut client: TcpStream, shared: &Shared) -> io::Result<()> {
|
|
343
|
+
track(shared, &client);
|
|
344
|
+
client.set_read_timeout(Some(HEADER_TIMEOUT))?;
|
|
345
|
+
let head = read_head(&mut client)?;
|
|
346
|
+
client.set_read_timeout(None)?;
|
|
347
|
+
|
|
348
|
+
if head.method.eq_ignore_ascii_case("CONNECT") {
|
|
349
|
+
let Some((host, port)) = parse_authority(&head.target, 443) else {
|
|
350
|
+
respond(&mut client, "400 Bad Request", "mxc: bad CONNECT target\n");
|
|
351
|
+
return Ok(());
|
|
352
|
+
};
|
|
353
|
+
if !shared.allowlist.permits(&host, port) {
|
|
354
|
+
deny(shared, &mut client, &host, port, "not in allow list");
|
|
355
|
+
return Ok(());
|
|
356
|
+
}
|
|
357
|
+
let mut upstream = match dial(shared, &host, port) {
|
|
358
|
+
Ok(s) => s,
|
|
359
|
+
Err(why) => {
|
|
360
|
+
deny(shared, &mut client, &host, port, &why);
|
|
361
|
+
return Ok(());
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
track(shared, &upstream);
|
|
365
|
+
client.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")?;
|
|
366
|
+
upstream.write_all(&head.rest)?;
|
|
367
|
+
return splice(client, upstream);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
let Some((host, port, path)) = parse_absolute_http(&head.target) else {
|
|
371
|
+
respond(
|
|
372
|
+
&mut client,
|
|
373
|
+
"400 Bad Request",
|
|
374
|
+
"mxc: expected CONNECT or an absolute http:// URL\n",
|
|
375
|
+
);
|
|
376
|
+
return Ok(());
|
|
377
|
+
};
|
|
378
|
+
if !shared.allowlist.permits(&host, port) {
|
|
379
|
+
deny(shared, &mut client, &host, port, "not in allow list");
|
|
380
|
+
return Ok(());
|
|
381
|
+
}
|
|
382
|
+
let mut upstream = match dial(shared, &host, port) {
|
|
383
|
+
Ok(s) => s,
|
|
384
|
+
Err(why) => {
|
|
385
|
+
deny(shared, &mut client, &host, port, &why);
|
|
386
|
+
return Ok(());
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
track(shared, &upstream);
|
|
390
|
+
let mut out = format!("{} {} {}\r\n", head.method, path, head.version);
|
|
391
|
+
for (k, v) in &head.headers {
|
|
392
|
+
let lower = k.to_ascii_lowercase();
|
|
393
|
+
if lower == "proxy-connection" || lower == "proxy-authorization" || lower == "connection" {
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
out.push_str(&format!("{k}: {v}\r\n"));
|
|
397
|
+
}
|
|
398
|
+
out.push_str("Connection: close\r\n\r\n");
|
|
399
|
+
upstream.write_all(out.as_bytes())?;
|
|
400
|
+
upstream.write_all(&head.rest)?;
|
|
401
|
+
splice(client, upstream)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
fn splice(client: TcpStream, upstream: TcpStream) -> io::Result<()> {
|
|
405
|
+
let mut c_read = client.try_clone()?;
|
|
406
|
+
let mut u_write = upstream.try_clone()?;
|
|
407
|
+
let up = thread::spawn(move || {
|
|
408
|
+
let _ = io::copy(&mut c_read, &mut u_write);
|
|
409
|
+
let _ = u_write.shutdown(Shutdown::Write);
|
|
410
|
+
});
|
|
411
|
+
let mut u_read = upstream;
|
|
412
|
+
let mut c_write = client;
|
|
413
|
+
let _ = io::copy(&mut u_read, &mut c_write);
|
|
414
|
+
let _ = c_write.shutdown(Shutdown::Write);
|
|
415
|
+
let _ = up.join();
|
|
416
|
+
Ok(())
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
#[cfg(test)]
|
|
420
|
+
mod tests {
|
|
421
|
+
use super::*;
|
|
422
|
+
|
|
423
|
+
fn list(entries: &[&str]) -> Allowlist {
|
|
424
|
+
Allowlist::parse(&entries.iter().map(|s| s.to_string()).collect::<Vec<_>>()).unwrap()
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
#[test]
|
|
428
|
+
fn exact_hosts_on_default_ports() {
|
|
429
|
+
let l = list(&["PyPI.org."]);
|
|
430
|
+
assert!(l.permits("pypi.org", 443));
|
|
431
|
+
assert!(l.permits("pypi.org", 80));
|
|
432
|
+
assert!(!l.permits("pypi.org", 22));
|
|
433
|
+
assert!(!l.permits("evilpypi.org", 443));
|
|
434
|
+
assert!(!l.permits("files.pypi.org", 443));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
#[test]
|
|
438
|
+
fn wildcard_matches_subdomains_only() {
|
|
439
|
+
let l = list(&["*.pythonhosted.org"]);
|
|
440
|
+
assert!(l.permits("files.pythonhosted.org", 443));
|
|
441
|
+
assert!(!l.permits("pythonhosted.org", 443));
|
|
442
|
+
assert!(!l.permits("evilpythonhosted.org", 443));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
#[test]
|
|
446
|
+
fn explicit_port() {
|
|
447
|
+
let l = list(&["example.com:8443", "[2001:db8::1]:443"]);
|
|
448
|
+
assert!(l.permits("example.com", 8443));
|
|
449
|
+
assert!(!l.permits("example.com", 443));
|
|
450
|
+
assert!(l.permits("[2001:db8::1]", 443));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
#[test]
|
|
454
|
+
fn rejects_bad_entries() {
|
|
455
|
+
for bad in ["", "a b", "10.0.0.0/8", "foo.*.com", "x:0", "x:http"] {
|
|
456
|
+
assert!(Allowlist::parse(&[bad.to_string()]).is_err(), "{bad}");
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
#[test]
|
|
461
|
+
fn private_addresses_are_refused_unless_listed() {
|
|
462
|
+
let l = list(&["internal.example", "10.1.2.3"]);
|
|
463
|
+
for ip in ["127.0.0.1", "10.0.0.5", "169.254.169.254", "::1", "fd00::1", "::ffff:127.0.0.1", "100.64.0.1"] {
|
|
464
|
+
assert!(!l.address_permitted(&ip.parse().unwrap()), "{ip}");
|
|
465
|
+
}
|
|
466
|
+
assert!(l.address_permitted(&"10.1.2.3".parse().unwrap()));
|
|
467
|
+
assert!(l.address_permitted(&"151.101.0.223".parse().unwrap()));
|
|
468
|
+
assert!(l.address_permitted(&"2a04:4e42::223".parse().unwrap()));
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
#[test]
|
|
472
|
+
fn absolute_http_targets() {
|
|
473
|
+
assert_eq!(
|
|
474
|
+
parse_absolute_http("http://user@Example.com:8080/a?b"),
|
|
475
|
+
Some(("example.com".into(), 8080, "/a?b".into()))
|
|
476
|
+
);
|
|
477
|
+
assert_eq!(parse_absolute_http("http://example.com"), Some(("example.com".into(), 80, "/".into())));
|
|
478
|
+
assert_eq!(parse_absolute_http("/relative"), None);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MXC
|
|
4
|
+
# Whether this host can start a sandbox, and why not, as returned by MXC.availability.
|
|
5
|
+
#
|
|
6
|
+
# availability = MXC.availability
|
|
7
|
+
# availability.supported? # => false
|
|
8
|
+
# availability.remediation # => "Unprivileged user namespaces are blocked. ..."
|
|
9
|
+
class Availability
|
|
10
|
+
# Returns the backend that was checked.
|
|
11
|
+
attr_reader :backend
|
|
12
|
+
|
|
13
|
+
# Returns why a sandbox cannot start, or nil when it can.
|
|
14
|
+
attr_reader :reason
|
|
15
|
+
|
|
16
|
+
# Returns a suggestion for making the host supported, or nil.
|
|
17
|
+
attr_reader :remediation
|
|
18
|
+
|
|
19
|
+
def self.probe(backend) # :nodoc:
|
|
20
|
+
result = Sandbox.new(backend:, timeout: 10).run('exit 0')
|
|
21
|
+
return new(backend:, supported: true) if result.success?
|
|
22
|
+
|
|
23
|
+
outcome = result.timed_out? ? 'timed out' : "exited with #{result.exit_code}"
|
|
24
|
+
new(backend:, supported: false, reason: "A sandboxed `exit 0` #{outcome}: #{result.stderr.strip}")
|
|
25
|
+
rescue Error => e
|
|
26
|
+
new(backend:, supported: false, reason: e.message, remediation: e.remediation)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(backend:, supported:, reason: nil, remediation: nil) # :nodoc:
|
|
30
|
+
@backend = backend
|
|
31
|
+
@supported = supported
|
|
32
|
+
@reason = reason
|
|
33
|
+
@remediation = remediation
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Returns true when a sandbox can start.
|
|
37
|
+
def supported?
|
|
38
|
+
@supported
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def inspect # :nodoc:
|
|
42
|
+
"#<MXC::Availability backend=#{backend.inspect} supported=#{supported?} reason=#{reason.inspect}>"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
data/lib/mxc/backend.rb
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MXC
|
|
4
|
+
# A sandbox backend MXC found on this host, as returned by MXC.backends.
|
|
5
|
+
#
|
|
6
|
+
# MXC.backends.first.name # => :bubblewrap
|
|
7
|
+
class Backend
|
|
8
|
+
# Returns the backend's name, such as <tt>:bubblewrap</tt> or <tt>:seatbelt</tt>.
|
|
9
|
+
attr_reader :name
|
|
10
|
+
|
|
11
|
+
# Returns the isolation tier MXC reports for the backend, or nil when it has none.
|
|
12
|
+
attr_reader :tier
|
|
13
|
+
|
|
14
|
+
# Returns the optional capabilities MXC reports for the backend, as Symbols.
|
|
15
|
+
attr_reader :capabilities
|
|
16
|
+
|
|
17
|
+
# Returns MXC's explanations for capabilities the backend lacks on this host.
|
|
18
|
+
attr_reader :warnings
|
|
19
|
+
|
|
20
|
+
def initialize(name:, tier: nil, capabilities: [], warnings: []) # :nodoc:
|
|
21
|
+
@name = name
|
|
22
|
+
@tier = tier
|
|
23
|
+
@capabilities = capabilities.freeze
|
|
24
|
+
@warnings = warnings.freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Returns true when the backend reports +capability+, such as <tt>:proxy_enforcement</tt>.
|
|
28
|
+
def supports?(capability)
|
|
29
|
+
capabilities.include?(capability)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def inspect # :nodoc:
|
|
33
|
+
"#<MXC::Backend name=#{name.inspect} tier=#{tier.inspect} capabilities=#{capabilities.inspect}>"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/mxc/chunk.rb
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MXC
|
|
4
|
+
# A piece of output from a running sandbox, as passed to the block of Sandbox#run and Process#each.
|
|
5
|
+
#
|
|
6
|
+
# sandbox.run("python3 build.py") { |chunk| print chunk.content }
|
|
7
|
+
class Chunk
|
|
8
|
+
# Returns the stream the output came from: <tt>:stdout</tt> or <tt>:stderr</tt>.
|
|
9
|
+
attr_reader :stream
|
|
10
|
+
|
|
11
|
+
# Returns the output as a String.
|
|
12
|
+
attr_reader :content
|
|
13
|
+
|
|
14
|
+
def initialize(stream:, content:) # :nodoc:
|
|
15
|
+
@stream = stream
|
|
16
|
+
@content = content
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Returns true when the chunk came from standard output.
|
|
20
|
+
def stdout?
|
|
21
|
+
stream == :stdout
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns true when the chunk came from standard error.
|
|
25
|
+
def stderr?
|
|
26
|
+
stream == :stderr
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def inspect # :nodoc:
|
|
30
|
+
"#<MXC::Chunk stream=#{stream.inspect} content=#{content.inspect}>"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MXC
|
|
4
|
+
# Global defaults for every sandbox. Change them with MXC.configure.
|
|
5
|
+
class Configuration
|
|
6
|
+
# Returns the default backend, <tt>:auto</tt> unless changed.
|
|
7
|
+
attr_reader :backend
|
|
8
|
+
|
|
9
|
+
# Returns the default timeout in seconds, or nil for none.
|
|
10
|
+
attr_reader :timeout
|
|
11
|
+
|
|
12
|
+
# Returns a Configuration with the built-in defaults.
|
|
13
|
+
def initialize
|
|
14
|
+
@backend = :auto
|
|
15
|
+
@timeout = nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Sets the default backend: <tt>:auto</tt>, <tt>:bubblewrap</tt>, or <tt>:seatbelt</tt>.
|
|
19
|
+
def backend=(name)
|
|
20
|
+
@backend = Sandbox.backend_name(name)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Sets the default timeout in seconds. Accepts anything that responds to +to_f+, or nil for none.
|
|
24
|
+
def timeout=(seconds)
|
|
25
|
+
@timeout = Sandbox.duration(seconds)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
data/lib/mxc/error.rb
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MXC
|
|
4
|
+
# Raised when MXC cannot start or finish a sandbox.
|
|
5
|
+
#
|
|
6
|
+
# rescue MXC::Error => e
|
|
7
|
+
# e.code # => :backend_error
|
|
8
|
+
# e.remediation # => "Install bubblewrap"
|
|
9
|
+
class Error < StandardError
|
|
10
|
+
# Returns MXC's error code as a Symbol, such as <tt>:backend_unavailable</tt>.
|
|
11
|
+
attr_reader :code
|
|
12
|
+
|
|
13
|
+
# Returns a suggestion for fixing the problem, or nil.
|
|
14
|
+
attr_reader :remediation
|
|
15
|
+
|
|
16
|
+
# Returns the operation that failed, as MXC names it, or nil.
|
|
17
|
+
attr_reader :operation
|
|
18
|
+
|
|
19
|
+
# Returns the operating system's own error code, or nil.
|
|
20
|
+
attr_reader :native_code
|
|
21
|
+
|
|
22
|
+
# Returns a new Error with +message+ and optional details.
|
|
23
|
+
def initialize(message = nil, code: nil, remediation: nil, operation: nil, native_code: nil)
|
|
24
|
+
super(message)
|
|
25
|
+
@code = code
|
|
26
|
+
@remediation = remediation
|
|
27
|
+
@operation = operation
|
|
28
|
+
@native_code = native_code
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Raised when this host cannot run the sandbox as configured, such as when Bubblewrap is
|
|
33
|
+
# missing, user namespaces are blocked, or a network policy needs tools that are not installed.
|
|
34
|
+
# Its +remediation+ says what to change.
|
|
35
|
+
class UnsupportedError < Error; end
|
|
36
|
+
end
|
data/lib/mxc/native.rb
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require "mxc/#{RUBY_VERSION[/\d+\.\d+/]}/mxc"
|
|
5
|
+
rescue LoadError
|
|
6
|
+
require 'mxc/mxc'
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
module MXC
|
|
10
|
+
module Native # :nodoc:
|
|
11
|
+
UNSUPPORTED_CODES = %i[backend_unavailable unsupported_containment].freeze
|
|
12
|
+
BACKEND_NAMES = { processcontainer: :process_container }.freeze
|
|
13
|
+
MISSING_BUBBLEWRAP = /bwrap\) is not installed/
|
|
14
|
+
INSTALL_BUBBLEWRAP = 'Install Bubblewrap 0.5.0 or newer (Debian/Ubuntu: apt-get install bubblewrap; ' \
|
|
15
|
+
'Fedora: dnf install bubblewrap; Arch: pacman -S bubblewrap)'
|
|
16
|
+
|
|
17
|
+
def self.backend_attributes
|
|
18
|
+
backends.map { |attributes| attributes.merge(name: BACKEND_NAMES.fetch(attributes[:name], attributes[:name])) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.error(message, code: nil, remediation: nil, **)
|
|
22
|
+
if message.match?(MISSING_BUBBLEWRAP)
|
|
23
|
+
UnsupportedError.new(message, code: :backend_unavailable, remediation: remediation || INSTALL_BUBBLEWRAP, **)
|
|
24
|
+
else
|
|
25
|
+
(UNSUPPORTED_CODES.include?(code) ? UnsupportedError : Error).new(message, code:, remediation:, **)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|