OXYGENHUB
# 77c8c24066ef oxygenflow / crates / flow / src / egress.rs
12.4 KiB · Rust · last changed flow builds for Windows, offering the WASM tier 10 days ago source blame history raw
1
//! Allow-listed egress for a microVM job (oxygenflow#55). The VM has no
2
//! network device: `flow-init` serves a proxy on the guest's 127.0.0.1
3
//! and relays each connection over vsock to this end, a Unix socket that
4
//! Firecracker connects for the guest. Here each `CONNECT host:443` is
5
//! checked against the sandbox's allow-list, the host is resolved (the
6
//! guest never resolves), any address that is not public is refused, the
7
//! decision goes into the job log, and the bytes are relayed untouched (no
8
//! TLS is opened here).
9
10
#[cfg(unix)]
11
use std::io::{Read, Write};
12
use std::net::IpAddr;
13
#[cfg(unix)]
14
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
15
#[cfg(unix)]
16
use std::os::unix::net::{UnixListener, UnixStream};
17
use std::path::Path;
18
#[cfg(unix)]
19
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
20
use std::sync::mpsc;
21
#[cfg(unix)]
22
use std::sync::Arc;
23
#[cfg(unix)]
24
use std::time::Duration;
25
26
/// The vsock port the guest's relay connects to on the host (CID 2).
27
pub const VSOCK_PORT: u32 = 3128;
28
/// Where the job's tools find the proxy inside the guest.
29
pub const GUEST_PROXY: &str = "http://127.0.0.1:3128";
30
/// The only port relayed: TLS.
31
const PORT: u16 = 443;
32
#[cfg(unix)]
33
/// Connections at once, per job.
34
const MAX_OPEN: usize = 64;
35
#[cfg(unix)]
36
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
37
#[cfg(unix)]
38
/// The longest request head read.
39
const MAX_HEAD: usize = 8 * 1024;
40
41
/// What a CONNECT asked for, or why it was refused.
42
#[derive(Debug, PartialEq, Eq)]
43
pub enum Ask {
44
    Connect { host: String, port: u16 },
45
    Refused(String),
46
}
47
48
/// Read a request head: `CONNECT host:port HTTP/1.x`, and only that.
49
pub fn parse_head(head: &str) -> Ask {
50
    let line = head.lines().next().unwrap_or("");
51
    let mut parts = line.split_whitespace();
52
    let (method, target, version) = (parts.next().unwrap_or(""), parts.next().unwrap_or(""), parts.next().unwrap_or(""));
53
    if method != "CONNECT" {
54
        return Ask::Refused(format!("only https is relayed (CONNECT), not {method:?}"));
55
    }
56
    if !version.starts_with("HTTP/1.") {
57
        return Ask::Refused("not an HTTP/1 request".into());
58
    }
59
    let Some((host, port)) = target.rsplit_once(':') else { return Ask::Refused(format!("CONNECT {target:?} names no port")) };
60
    let Ok(port) = port.parse::<u16>() else { return Ask::Refused(format!("CONNECT {target:?} has no valid port")) };
61
    let host = host.trim_start_matches('[').trim_end_matches(']').trim_end_matches('.').to_ascii_lowercase();
62
    if host.is_empty() {
63
        return Ask::Refused("CONNECT names no host".into());
64
    }
65
    Ask::Connect { host, port }
66
}
67
68
/// Whether the job may go to `host:port`: the port is 443, the host is a
69
/// name (an address is refused: the allow-list names hosts) the list allows.
70
pub fn decide(allow: &[String], host: &str, port: u16) -> Result<(), String> {
71
    if port != PORT {
72
        return Err(format!("only port {PORT} is relayed"));
73
    }
74
    if host.parse::<IpAddr>().is_ok() {
75
        return Err("an address, not a name: the allow-list names hosts".into());
76
    }
77
    if !flow_core::egress_allows(allow, host) {
78
        return Err("not in the sandbox's egress allow-list".into());
79
    }
80
    Ok(())
81
}
82
83
/// Whether an address is on the public internet: not loopback, private,
84
/// shared (CGNAT), link-local (cloud metadata among them), multicast,
85
/// unspecified or documentation, nor an IPv6 local range or a mapped form.
86
pub fn public(ip: IpAddr) -> bool {
87
    match ip {
88
        IpAddr::V4(v4) => {
89
            let o = v4.octets();
90
            !(v4.is_loopback()
91
                || v4.is_private()
92
                || v4.is_link_local()
93
                || v4.is_multicast()
94
                || v4.is_broadcast()
95
                || v4.is_unspecified()
96
                || v4.is_documentation()
97
                || o[0] == 0
98
                || (o[0] == 100 && (o[1] & 0xc0) == 64)
99
                || (o[0] == 192 && o[1] == 0 && o[2] == 0)
100
                || o[0] >= 240)
101
        }
102
        IpAddr::V6(v6) => {
103
            if let Some(v4) = v6.to_ipv4_mapped() {
104
                return public(IpAddr::V4(v4));
105
            }
106
            let s = v6.segments();
107
            !(v6.is_loopback()
108
                || v6.is_unspecified()
109
                || v6.is_multicast()
110
                || (s[0] & 0xfe00) == 0xfc00
111
                || (s[0] & 0xffc0) == 0xfe80
112
                || (s[0] == 0x64 && s[1] == 0xff9b)
113
                || (s[0] == 0x2001 && s[1] == 0x0db8))
114
        }
115
    }
116
}
117
118
#[cfg(unix)]
119
/// The proxy for one job: listening until dropped.
120
pub struct Proxy {
121
    stop: Arc<AtomicBool>,
122
    path: std::path::PathBuf,
123
}
124
125
#[cfg(unix)]
126
impl Drop for Proxy {
127
    fn drop(&mut self) {
128
        self.stop.store(true, Ordering::SeqCst);
129
        // Wake the accept loop so it sees the stop.
130
        let _ = UnixStream::connect(&self.path);
131
        let _ = std::fs::remove_file(&self.path);
132
    }
133
}
134
135
#[cfg(unix)]
136
/// Listen on `socket` (Firecracker's `uds_path` and `_3128`) for the
137
/// guest's connections; each decision is sent to `log` as a line.
138
pub fn start(socket: &Path, allow: Vec<String>, log: mpsc::Sender<String>) -> std::io::Result<Proxy> {
139
    let _ = std::fs::remove_file(socket);
140
    let listener = UnixListener::bind(socket)?;
141
    let stop = Arc::new(AtomicBool::new(false));
142
    let open = Arc::new(AtomicUsize::new(0));
143
    let allow = Arc::new(allow);
144
    let s = stop.clone();
145
    std::thread::spawn(move || {
146
        for conn in listener.incoming() {
147
            if s.load(Ordering::SeqCst) {
148
                break;
149
            }
150
            let Ok(conn) = conn else { continue };
151
            if open.load(Ordering::SeqCst) >= MAX_OPEN {
152
                let _ = refuse(conn, "too many connections at once");
153
                let _ = log.send(format!("egress: refused a connection: {MAX_OPEN} already open"));
154
                continue;
155
            }
156
            open.fetch_add(1, Ordering::SeqCst);
157
            let (allow, log, open) = (allow.clone(), log.clone(), open.clone());
158
            std::thread::spawn(move || {
159
                serve(conn, &allow, &log);
160
                open.fetch_sub(1, Ordering::SeqCst);
161
            });
162
        }
163
    });
164
    Ok(Proxy { stop, path: socket.to_path_buf() })
165
}
166
167
#[cfg(unix)]
168
fn refuse(mut conn: UnixStream, why: &str) -> std::io::Result<()> {
169
    let body = format!("{why}\n");
170
    write!(conn, "HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len())
171
}
172
173
#[cfg(unix)]
174
/// Read up to the blank line that ends a request head.
175
fn read_head(conn: &mut UnixStream) -> std::io::Result<String> {
176
    let mut head = Vec::new();
177
    let mut byte = [0u8; 1];
178
    while !head.ends_with(b"\r\n\r\n") {
179
        if head.len() >= MAX_HEAD || conn.read(&mut byte)? == 0 {
180
            break;
181
        }
182
        head.push(byte[0]);
183
    }
184
    Ok(String::from_utf8_lossy(&head).into_owned())
185
}
186
187
#[cfg(unix)]
188
fn serve(mut conn: UnixStream, allow: &[String], log: &mpsc::Sender<String>) {
189
    let _ = conn.set_read_timeout(Some(Duration::from_secs(30)));
190
    let head = match read_head(&mut conn) {
191
        Ok(h) => h,
192
        Err(_) => return,
193
    };
194
    let (host, port) = match parse_head(&head) {
195
        Ask::Connect { host, port } => (host, port),
196
        Ask::Refused(why) => {
197
            let _ = log.send(format!("egress: refused: {why}"));
198
            let _ = refuse(conn, &why);
199
            return;
200
        }
201
    };
202
    if let Err(why) = decide(allow, &host, port) {
203
        let _ = log.send(format!("egress: refused {host}:{port}: {why}"));
204
        let _ = refuse(conn, &format!("{host}:{port}: {why}"));
205
        return;
206
    }
207
    let addrs: Vec<SocketAddr> = match (host.as_str(), port).to_socket_addrs() {
208
        Ok(a) => a.collect(),
209
        Err(e) => {
210
            let _ = log.send(format!("egress: refused {host}:{port}: it could not be resolved ({e})"));
211
            let _ = refuse(conn, &format!("{host} could not be resolved"));
212
            return;
213
        }
214
    };
215
    if let Some(bad) = addrs.iter().find(|a| !public(a.ip())) {
216
        let why = format!("it resolves to {}, which is not a public address", bad.ip());
217
        let _ = log.send(format!("egress: refused {host}:{port}: {why}"));
218
        let _ = refuse(conn, &format!("{host}: {why}"));
219
        return;
220
    }
221
    let out = addrs.iter().find_map(|a| TcpStream::connect_timeout(a, CONNECT_TIMEOUT).ok());
222
    let Some(out) = out else {
223
        let _ = log.send(format!("egress: {host}:{port} is allowed but did not answer"));
224
        let _ = conn.write_all(b"HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
225
        return;
226
    };
227
    let _ = log.send(format!("egress: allowed {host}:{port}"));
228
    if conn.write_all(b"HTTP/1.1 200 Connection established\r\n\r\n").is_err() {
229
        return;
230
    }
231
    let _ = conn.set_read_timeout(None);
232
    relay(conn, out);
233
}
234
235
#[cfg(unix)]
236
/// Copy both ways until either side closes.
237
fn relay(guest: UnixStream, out: TcpStream) {
238
    let (Ok(mut guest_r), Ok(mut out_w)) = (guest.try_clone(), out.try_clone()) else { return };
239
    let up = std::thread::spawn(move || {
240
        let _ = std::io::copy(&mut guest_r, &mut out_w);
241
        let _ = out_w.shutdown(std::net::Shutdown::Write);
242
    });
243
    let (mut out_r, mut guest_w) = (out, guest);
244
    let _ = std::io::copy(&mut out_r, &mut guest_w);
245
    let _ = guest_w.shutdown(std::net::Shutdown::Write);
246
    let _ = up.join();
247
}
248
249
/// The egress proxy serves a microVM's vsock, which only Linux has.
250
#[cfg(not(unix))]
251
pub struct Proxy;
252
253
#[cfg(not(unix))]
254
pub fn start(_socket: &Path, _allow: Vec<String>, _log: mpsc::Sender<String>) -> std::io::Result<Proxy> {
255
    Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "egress serves microVMs, which run only on Linux"))
256
}
257
258
#[cfg(all(test, unix))]
259
mod tests {
260
    use super::*;
261
262
    fn allow() -> Vec<String> {
263
        vec!["*.googleapis.com".into(), "registry.opentofu.org".into()]
264
    }
265
266
    #[test]
267
    fn only_connect_to_an_allowed_name_on_443_is_let_through() {
268
        assert_eq!(
269
            parse_head("CONNECT storage.googleapis.com:443 HTTP/1.1\r\nHost: x\r\n\r\n"),
270
            Ask::Connect { host: "storage.googleapis.com".into(), port: 443 }
271
        );
272
        assert!(matches!(parse_head("GET http://example.com/ HTTP/1.1\r\n\r\n"), Ask::Refused(w) if w.contains("only https")));
273
        assert!(matches!(parse_head("CONNECT example.com HTTP/1.1\r\n\r\n"), Ask::Refused(w) if w.contains("no port")));
274
        assert_eq!(decide(&allow(), "storage.googleapis.com", 443), Ok(()));
275
        assert_eq!(decide(&allow(), "registry.opentofu.org", 443), Ok(()));
276
        assert!(decide(&allow(), "example.com", 443).unwrap_err().contains("allow-list"));
277
        assert!(decide(&allow(), "registry.opentofu.org", 22).unwrap_err().contains("only port 443"));
278
        assert!(decide(&allow(), "142.250.1.1", 443).unwrap_err().contains("names hosts"));
279
    }
280
281
    #[test]
282
    fn private_addresses_are_not_public() {
283
        for ip in [
284
            "127.0.0.1",
285
            "10.1.2.3",
286
            "192.168.1.1",
287
            "172.16.0.1",
288
            "169.254.169.254",
289
            "100.64.0.1",
290
            "0.0.0.0",
291
            "::1",
292
            "fd00::1",
293
            "fe80::1",
294
            "::ffff:10.0.0.1",
295
        ] {
296
            assert!(!public(ip.parse().unwrap()), "{ip}");
297
        }
298
        for ip in ["142.250.1.1", "2a00:1450:4009::200e"] {
299
            assert!(public(ip.parse().unwrap()), "{ip}");
300
        }
301
    }
302
303
    #[test]
304
    fn the_proxy_refuses_and_logs_a_host_not_allowed() {
305
        let dir = tempfile::tempdir().unwrap();
306
        let sock = dir.path().join("v.sock_3128");
307
        let (tx, rx) = mpsc::channel();
308
        let proxy = start(&sock, allow(), tx).unwrap();
309
        let ask = |head: &str| {
310
            let mut c = UnixStream::connect(&sock).unwrap();
311
            c.write_all(head.as_bytes()).unwrap();
312
            let mut answer = String::new();
313
            c.read_to_string(&mut answer).unwrap();
314
            answer
315
        };
316
        let a = ask("CONNECT example.com:443 HTTP/1.1\r\n\r\n");
317
        assert!(a.starts_with("HTTP/1.1 403") && a.contains("not in the sandbox's egress allow-list"), "{a}");
318
        assert_eq!(
319
            rx.recv_timeout(Duration::from_secs(5)).unwrap(),
320
            "egress: refused example.com:443: not in the sandbox's egress allow-list"
321
        );
322
        let a = ask("GET http://storage.googleapis.com/ HTTP/1.1\r\n\r\n");
323
        assert!(a.starts_with("HTTP/1.1 403"), "{a}");
324
        assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap().contains("only https"));
325
        drop(proxy);
326
        assert!(!sock.exists(), "the socket goes with the job");
327
    }
328
}