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