Skip to main content

sail/
forward.rs

1//! Local port forwarding for interactive sessions. A [`PortForward`] listens on
2//! the user's machine and forwards each accepted connection to the same port on
3//! the box's localhost, so a sandbox's localhost servers (dev servers, OAuth
4//! login callbacks) are reachable locally.
5
6use std::net::{Ipv4Addr, Ipv6Addr};
7use std::sync::Arc;
8use std::time::Duration;
9
10use tokio::io::{AsyncReadExt, AsyncWriteExt};
11use tokio::net::{TcpListener, TcpStream};
12use tokio::sync::mpsc;
13use tokio_stream::wrappers::ReceiverStream;
14
15use crate::error::SailError;
16use crate::pb::workerproxy::v1 as pb;
17use crate::worker::WorkerProxy;
18
19/// Buffered tunnel frames from the local socket toward the guest; bounds memory
20/// and applies backpressure when the uplink is slow.
21const TUNNEL_CHANNEL_CAP: usize = 32;
22/// Read chunk for the local-socket read loop.
23const TUNNEL_READ_CHUNK: usize = 32 * 1024;
24
25/// A live local port forward. Dropping it stops accepting new connections;
26/// connections already open finish on their own.
27pub struct PortForward {
28    local_port: u16,
29    accept: tokio::task::JoinHandle<()>,
30}
31
32impl Drop for PortForward {
33    fn drop(&mut self) {
34        self.accept.abort();
35    }
36}
37
38impl PortForward {
39    /// The local port the forward is listening on (`127.0.0.1`, and `::1` when
40    /// available). This equals the requested port, or an OS-picked free port when
41    /// 0 was requested.
42    pub fn local_port(&self) -> u16 {
43        self.local_port
44    }
45}
46
47/// Start forwarding `127.0.0.1:local_port` to the sailbox's guest-local
48/// `remote_port`. The listener binds up front (so a bind failure surfaces here),
49/// then accepts in the background. Passing `local_port` 0 lets the OS pick a free
50/// port, reported by [`PortForward::local_port`].
51pub async fn forward_port(
52    worker: Arc<WorkerProxy>,
53    endpoint: String,
54    sailbox_id: String,
55    local_port: u16,
56    remote_port: u16,
57) -> Result<PortForward, SailError> {
58    let v4 = TcpListener::bind((Ipv4Addr::LOCALHOST, local_port))
59        .await
60        .map_err(|err| SailError::Internal {
61            message: format!("bind 127.0.0.1:{local_port}: {err}"),
62        })?;
63    let bound = v4.local_addr().map_or(local_port, |addr| addr.port());
64    // Also listen on the IPv6 loopback on the same port: some systems resolve
65    // `localhost` to `::1`, so catching both keeps a forwarded URL reachable
66    // however the browser resolves it.
67    let v6 = match TcpListener::bind((Ipv6Addr::LOCALHOST, bound)).await {
68        Ok(v6) => Some(v6),
69        // The IPv6 loopback port is held by another service. A browser resolving
70        // `localhost` to `::1` would reach that service instead of this forward,
71        // and a login redirect to the exact port cannot be rewritten afterward, so
72        // treat the forward as unavailable rather than own only half the port.
73        Err(err) if err.kind() == std::io::ErrorKind::AddrInUse => {
74            return Err(SailError::Internal {
75                message: format!("bind [::1]:{bound}: {err}"),
76            });
77        }
78        // IPv6 loopback is otherwise unavailable (e.g. a host without IPv6). The
79        // v4 listener suffices, since `localhost` then resolves to 127.0.0.1.
80        Err(_) => None,
81    };
82    let accept = tokio::spawn(async move {
83        loop {
84            let accepted = match &v6 {
85                Some(v6) => tokio::select! {
86                    conn = v4.accept() => conn,
87                    conn = v6.accept() => conn,
88                },
89                None => v4.accept().await,
90            };
91            match accepted {
92                Ok((conn, _)) => {
93                    let worker = Arc::clone(&worker);
94                    let endpoint = endpoint.clone();
95                    let sailbox_id = sailbox_id.clone();
96                    tokio::spawn(async move {
97                        let _ =
98                            tunnel_connection(worker, &endpoint, &sailbox_id, remote_port, conn)
99                                .await;
100                    });
101                }
102                // A transient accept error (e.g. fd exhaustion) must not kill the
103                // forward the caller still holds; back off briefly and keep going.
104                Err(_) => tokio::time::sleep(Duration::from_millis(50)).await,
105            }
106        }
107    });
108    Ok(PortForward {
109        local_port: bound,
110        accept,
111    })
112}
113
114/// Pump one accepted local connection through a `TunnelSailboxPort` stream: the
115/// handshake names the sailbox and port, then bytes flow both ways until either
116/// side closes.
117async fn tunnel_connection(
118    worker: Arc<WorkerProxy>,
119    endpoint: &str,
120    sailbox_id: &str,
121    remote_port: u16,
122    conn: TcpStream,
123) -> Result<(), SailError> {
124    let (tx, rx) = mpsc::channel::<pb::TunnelSailboxPortRequest>(TUNNEL_CHANNEL_CAP);
125    // The handshake frame goes first; the channel preserves order.
126    let handshake = pb::TunnelSailboxPortRequest {
127        sailbox_id: sailbox_id.to_string(),
128        port: u32::from(remote_port),
129        data: Vec::new(),
130        eof: false,
131    };
132    if tx.send(handshake).await.is_err() {
133        return Ok(());
134    }
135    let (mut read_half, mut write_half) = conn.into_split();
136
137    // Start the local -> guest pump before awaiting the response. The guest
138    // dials the service and, for a request/response protocol (HTTP dev servers,
139    // OAuth callbacks), waits for request bytes before it responds; the response
140    // await resolves only once the server sends its first frame, so the request
141    // must already be flowing or the first connection deadlocks. Half-close on
142    // EOF.
143    let uplink = tokio::spawn(async move {
144        let mut buf = vec![0u8; TUNNEL_READ_CHUNK];
145        loop {
146            match read_half.read(&mut buf).await {
147                Ok(n) if n > 0 => {
148                    if tx.send(data_frame(buf[..n].to_vec())).await.is_err() {
149                        break;
150                    }
151                }
152                // No more request bytes, whether a clean EOF or a local reset
153                // (a browser aborting a forwarded request): half-close with an
154                // eof frame so the guest CloseWrites its dialed connection and
155                // can finish replying. Dropping the stream without this frame
156                // would not carry the half-close, leaving a request/response
157                // service waiting on the guest until the shell exits.
158                _ => {
159                    let _ = tx.send(eof_frame()).await;
160                    break;
161                }
162            }
163        }
164    });
165
166    let response = match open_tunnel_stream(&worker, endpoint, rx).await {
167        Ok(response) => response,
168        Err(err) => {
169            uplink.abort();
170            return Err(err);
171        }
172    };
173    let mut server = response.into_inner();
174
175    // guest -> local: write frames to the socket, half-close on EOF.
176    while let Ok(Some(frame)) = server.message().await {
177        if !frame.data.is_empty() && write_half.write_all(&frame.data).await.is_err() {
178            break;
179        }
180        if frame.eof {
181            let _ = write_half.shutdown().await;
182        }
183    }
184    uplink.abort();
185    Ok(())
186}
187
188/// The port a URL targets on a server in the sandbox, if any: its host is
189/// `localhost`, the loopback `127.0.0.1`, or a wildcard (`0.0.0.0` or `::`), each
190/// of which the guest reaches at `127.0.0.1`. The port is forwarded and the URL
191/// rewritten to the bound local address for opening.
192pub fn forwardable_local_port(url: &str) -> Option<u16> {
193    let parsed = url::Url::parse(url).ok()?;
194    if is_forwardable_local_host(&parsed) {
195        return parsed.port_or_known_default();
196    }
197    None
198}
199
200// A host the guest reaches at 127.0.0.1: `localhost`, the loopback 127.0.0.1
201// itself, the IPv4 wildcard 0.0.0.0, or the IPv6 wildcard `::`. A `::` listener is
202// dual-stack and so answers on 127.0.0.1, which is what the port scanner forwards
203// and the tunnel dials. Other 127.0.0.0/8 addresses and the IPv6 loopback `::1`
204// are excluded, since the tunnel dials 127.0.0.1 specifically.
205fn is_forwardable_local_host(url: &url::Url) -> bool {
206    match url.host() {
207        Some(url::Host::Domain(domain)) => domain == "localhost",
208        Some(url::Host::Ipv4(ip)) => ip == Ipv4Addr::LOCALHOST || ip.is_unspecified(),
209        Some(url::Host::Ipv6(ip)) => ip.is_unspecified(),
210        _ => false,
211    }
212}
213
214/// Whether `url` targets a loopback address the sandbox meant for itself but the
215/// tunnel does not dial (any loopback other than 127.0.0.1: a different
216/// 127.0.0.0/8 address, the IPv6 loopback `::1`, or an IPv4-mapped loopback like
217/// `::ffff:127.0.0.1`). Such a URL must not be opened against the user's own
218/// loopback.
219pub fn is_unforwardable_loopback_url(url: &str) -> bool {
220    let Ok(parsed) = url::Url::parse(url) else {
221        return false;
222    };
223    match parsed.host() {
224        Some(url::Host::Ipv4(ip)) => ip.is_loopback() && ip != Ipv4Addr::LOCALHOST,
225        // An IPv4-mapped address (`::ffff:a.b.c.d`) reaches the same host as
226        // `a.b.c.d`, and the port scanner never forwards a mapped bind, so any
227        // mapped loopback is one the tunnel cannot reach.
228        Some(url::Host::Ipv6(ip)) => match ip.to_ipv4_mapped() {
229            Some(v4) => v4.is_loopback(),
230            None => ip.is_loopback(),
231        },
232        _ => false,
233    }
234}
235
236/// If `url` targets a forwarded port on a sandbox server, rewrite it to reach the
237/// forward. A wildcard bind host (`0.0.0.0` or `::`) is not itself connectable,
238/// so it is pointed at `localhost`, which resolves to the forward's loopback. A
239/// `localhost` or `127.0.0.1` host is left exactly as the guest wrote it, so an
240/// HTTPS certificate for that name still verifies (and localhost-scoped cookies
241/// and origins are preserved); the forward listens on the same port, so nothing
242/// else changes. Returns the URL unchanged when it does not target a forwardable
243/// local host or the port is not forwarded.
244pub fn rewrite_loopback_url(url: &str, local_port_for: impl Fn(u16) -> Option<u16>) -> String {
245    let Ok(mut parsed) = url::Url::parse(url) else {
246        return url.to_string();
247    };
248    if !is_forwardable_local_host(&parsed) {
249        return url.to_string();
250    }
251    let Some(remote) = parsed.port_or_known_default() else {
252        return url.to_string();
253    };
254    let Some(local) = local_port_for(remote) else {
255        return url.to_string();
256    };
257    let wildcard = matches!(parsed.host(), Some(url::Host::Ipv4(ip)) if ip.is_unspecified())
258        || matches!(parsed.host(), Some(url::Host::Ipv6(ip)) if ip.is_unspecified());
259    if !wildcard {
260        return url.to_string();
261    }
262    if parsed.set_host(Some("localhost")).is_ok() && parsed.set_port(Some(local)).is_ok() {
263        parsed.to_string()
264    } else {
265        url.to_string()
266    }
267}
268
269/// The loopback callback an external login URL will redirect to, read from a
270/// `redirect_uri`/`redirect_url` query parameter.
271pub enum RedirectCallback {
272    /// A forwardable localhost port. The login completes once that port is
273    /// forwarded, so the shell holds the browser open until then.
274    Forwardable(u16),
275    /// A loopback the tunnel cannot dial (a non-`.0.0.1` 127/8 address, `::1`, or
276    /// an IPv4-mapped loopback). The provider's redirect would hit the user's own
277    /// machine rather than the sandbox, so the login cannot complete.
278    Unreachable,
279}
280
281/// Classify the loopback callback an external login URL redirects to, if any, so
282/// the shell can decline to open a login whose redirect would not reach the
283/// sandbox.
284pub fn redirect_callback(url: &str) -> Option<RedirectCallback> {
285    let parsed = url::Url::parse(url).ok()?;
286    for (key, value) in parsed.query_pairs() {
287        if key == "redirect_uri" || key == "redirect_url" {
288            if let Some(port) = forwardable_local_port(&value) {
289                return Some(RedirectCallback::Forwardable(port));
290            }
291            if is_unforwardable_loopback_url(&value) {
292                return Some(RedirectCallback::Unreachable);
293            }
294        }
295    }
296    None
297}
298
299/// Open the `TunnelSailboxPort` stream, feeding it the request frames from `rx`.
300type TunnelResponse = tonic::Response<tonic::Streaming<pb::TunnelSailboxPortResponse>>;
301
302async fn open_tunnel_stream(
303    worker: &Arc<WorkerProxy>,
304    endpoint: &str,
305    rx: mpsc::Receiver<pb::TunnelSailboxPortRequest>,
306) -> Result<TunnelResponse, SailError> {
307    let request = worker.request_for(ReceiverStream::new(rx), &[], /* timeout */ None)?;
308    worker
309        .client_for(endpoint)?
310        .tunnel_sailbox_port(request)
311        .await
312        .map_err(|status| SailError::from_file_rpc_status(&status))
313}
314
315fn data_frame(data: Vec<u8>) -> pb::TunnelSailboxPortRequest {
316    pb::TunnelSailboxPortRequest {
317        sailbox_id: String::new(),
318        port: 0,
319        data,
320        eof: false,
321    }
322}
323
324fn eof_frame() -> pb::TunnelSailboxPortRequest {
325    pb::TunnelSailboxPortRequest {
326        sailbox_id: String::new(),
327        port: 0,
328        data: Vec::new(),
329        eof: true,
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use std::collections::HashMap;
337
338    #[test]
339    fn forwardable_local_port_covers_loopback_wildcard_and_defaults() {
340        assert_eq!(forwardable_local_port("http://localhost:5173/"), Some(5173));
341        assert_eq!(
342            forwardable_local_port("http://127.0.0.1:9000/x"),
343            Some(9000)
344        );
345        // Wildcard binds are reachable at 127.0.0.1 in the guest, including the
346        // dual-stack IPv6 wildcard `::`.
347        assert_eq!(forwardable_local_port("http://0.0.0.0:3000/"), Some(3000));
348        assert_eq!(forwardable_local_port("http://[::]:3000/"), Some(3000));
349        // A default-port target uses the scheme's known default.
350        assert_eq!(forwardable_local_port("http://localhost/cb"), Some(80));
351        assert_eq!(forwardable_local_port("https://localhost/cb"), Some(443));
352        // Not forwardable: a non-local URL, the IPv6 loopback `::1`, or a
353        // 127.0.0.0/8 address other than 127.0.0.1 (the tunnel dials 127.0.0.1).
354        assert_eq!(forwardable_local_port("https://example.com:8080/"), None);
355        assert_eq!(forwardable_local_port("http://[::1]:5173/"), None);
356        assert_eq!(
357            forwardable_local_port("http://[::ffff:127.0.0.1]:5173/"),
358            None
359        );
360        assert_eq!(forwardable_local_port("http://127.0.0.2:3000/"), None);
361    }
362
363    #[test]
364    fn is_unforwardable_loopback_url_matches_undialed_loopbacks() {
365        // A loopback the tunnel does not dial: `::1`, 127/8 other than .0.0.1, or
366        // an IPv4-mapped loopback (which `is_loopback` alone does not catch).
367        assert!(is_unforwardable_loopback_url("http://[::1]:5173/"));
368        assert!(is_unforwardable_loopback_url("http://127.0.0.2:3000/"));
369        assert!(is_unforwardable_loopback_url(
370            "http://[::ffff:127.0.0.1]:3000/"
371        ));
372        // Forwardable and external hosts are not flagged.
373        assert!(!is_unforwardable_loopback_url("http://localhost:8080/"));
374        assert!(!is_unforwardable_loopback_url("http://127.0.0.1:8080/"));
375        assert!(!is_unforwardable_loopback_url("http://0.0.0.0:8080/"));
376        assert!(!is_unforwardable_loopback_url("http://[::]:8080/"));
377        assert!(!is_unforwardable_loopback_url("https://example.com/"));
378    }
379
380    #[test]
381    fn rewrite_loopback_url_only_rewrites_wildcard_hosts() {
382        let mut forwards = HashMap::new();
383        forwards.insert(3000u16, 3000u16); // forwards are one-to-one
384        forwards.insert(8080u16, 8080u16);
385        let lookup = |remote: u16| forwards.get(&remote).copied();
386
387        // A localhost or 127.0.0.1 host is left exactly as written, so an HTTPS
388        // cert for that name still verifies and localhost origins are preserved.
389        assert_eq!(
390            rewrite_loopback_url("https://localhost:8080/app", lookup),
391            "https://localhost:8080/app"
392        );
393        assert_eq!(
394            rewrite_loopback_url("http://127.0.0.1:3000/x", lookup),
395            "http://127.0.0.1:3000/x"
396        );
397        // A wildcard host is not connectable, so it is pointed at localhost.
398        assert_eq!(
399            rewrite_loopback_url("http://0.0.0.0:3000/x", lookup),
400            "http://localhost:3000/x"
401        );
402        assert_eq!(
403            rewrite_loopback_url("http://[::]:3000/x", lookup),
404            "http://localhost:3000/x"
405        );
406        // Unforwarded and non-loopback URLs are left alone.
407        assert_eq!(
408            rewrite_loopback_url("http://localhost:9999/x", lookup),
409            "http://localhost:9999/x"
410        );
411        assert_eq!(
412            rewrite_loopback_url("https://example.com:8080/x", lookup),
413            "https://example.com:8080/x"
414        );
415    }
416
417    #[test]
418    fn redirect_callback_classifies_the_redirect_uri() {
419        use RedirectCallback::{Forwardable, Unreachable};
420        // A forwardable localhost callback returns its port (redirect_uri or
421        // redirect_url, encoded or plain, localhost or 127.0.0.1).
422        assert!(matches!(
423            redirect_callback(
424                "https://example.com/oauth?client_id=x&redirect_uri=http%3A%2F%2Flocalhost%3A43222%2Fcallback"
425            ),
426            Some(Forwardable(43222))
427        ));
428        assert!(matches!(
429            redirect_callback("https://id.example/a?redirect_url=http://127.0.0.1:5000/cb"),
430            Some(Forwardable(5000))
431        ));
432        // A loopback callback the tunnel can't reach is unreachable, not opened.
433        assert!(matches!(
434            redirect_callback("https://example.com/oauth?redirect_uri=http://[::1]:5173/cb"),
435            Some(Unreachable)
436        ));
437        assert!(matches!(
438            redirect_callback("https://example.com/oauth?redirect_uri=http://127.0.0.2:5173/cb"),
439            Some(Unreachable)
440        ));
441        // A remote redirect, or none at all, is not a loopback callback.
442        assert!(
443            redirect_callback("https://example.com/oauth?redirect_uri=https://example.com/cb")
444                .is_none()
445        );
446        assert!(redirect_callback("https://example.com/login").is_none());
447    }
448}