Skip to main content

tailscale/
loopback.rs

1//! Host-loopback SOCKS5 proxy that dials INTO the tailnet overlay (Go `tsnet.Server.Loopback`,
2//! SOCKS5 half).
3//!
4//! This serves a SOCKS5 (RFC 1928) proxy with required username/password auth (RFC 1929) on a
5//! `127.0.0.1` host-loopback address, so a non-Rust host process can reach tailnet peers through the
6//! proxy. Every accepted `CONNECT` is dialed INTO the overlay via the device's netstack — never out a
7//! host socket to the destination — so the host's real origin IP is never used to reach the target.
8//!
9//! The LocalAPI HTTP surface that Go also serves on the loopback is intentionally NOT provided here:
10//! this fork exposes status/whois/id-token natively on [`crate::Device`], and Go itself recommends
11//! the in-process client over the loopback LocalAPI. The listener therefore serves SOCKS5 directly,
12//! with no SOCKS-vs-HTTP first-byte demux.
13
14use std::{
15    future::Future,
16    net::{Ipv4Addr, SocketAddr},
17    pin::Pin,
18    sync::Arc,
19    time::Duration,
20};
21
22use tokio::{
23    io::{AsyncReadExt, AsyncWriteExt},
24    net::{TcpListener, TcpStream},
25    sync::Semaphore,
26    task::AbortHandle,
27};
28use ts_netstack_smoltcp::{CreateSocket, netcore::Channel};
29
30use crate::{Error, InternalErrorKind};
31
32/// A cloneable, dep-free MagicDNS resolver: maps a name to a tailnet IPv4, or `None` if unresolved.
33///
34/// The concrete closure (built in [`crate::Device::loopback`]) captures clones of the device's
35/// control + peer-tracker actor refs and replicates [`crate::Device::resolve`]. Boxing it here keeps
36/// the kameo actor types out of this module so the `tailscale` crate needs no new dependency.
37pub(crate) type Resolver = Arc<
38    dyn Fn(String) -> Pin<Box<dyn Future<Output = Result<Option<Ipv4Addr>, Error>> + Send>>
39        + Send
40        + Sync,
41>;
42
43/// SOCKS protocol version (`0x05`).
44const SOCKS5_VER: u8 = 0x05;
45/// SOCKS5 auth method: username/password (RFC 1929).
46const METHOD_USER_PASS: u8 = 0x02;
47/// SOCKS5 "no acceptable methods" selector.
48const METHOD_NONE: u8 = 0xFF;
49/// RFC 1929 username/password sub-negotiation version.
50const AUTH_VER: u8 = 0x01;
51/// SOCKS5 CONNECT command.
52const CMD_CONNECT: u8 = 0x01;
53/// SOCKS5 address type: IPv4.
54const ATYP_IPV4: u8 = 0x01;
55/// SOCKS5 address type: domain name.
56const ATYP_DOMAIN: u8 = 0x03;
57/// SOCKS5 address type: IPv6.
58const ATYP_IPV6: u8 = 0x04;
59/// SOCKS5 reply: command not supported.
60const REP_CMD_NOT_SUPPORTED: u8 = 0x07;
61/// SOCKS5 reply: address type not supported.
62const REP_ATYP_NOT_SUPPORTED: u8 = 0x08;
63/// Upper bound on the SOCKS5 negotiation (greeting + auth + request + overlay dial). A local client
64/// that connects but stalls mid-handshake is dropped rather than parking a task forever. The splice
65/// that follows has no deadline — a proxied connection is legitimately long-lived.
66const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(30);
67/// The fixed SOCKS5 username this proxy requires (Go uses `tsnet`).
68const PROXY_USERNAME: &str = "tsnet";
69
70/// The dial target parsed out of a SOCKS5 CONNECT request, before any I/O is performed.
71///
72/// Either an explicit IPv4 overlay address (`ATYP=0x01`) or a MagicDNS name (`ATYP=0x03`) plus a
73/// destination port. The pure [`parse_request`] helper produces this from a request byte buffer so
74/// the ATYP/CMD branching is unit-testable without a socket.
75#[derive(Debug, Clone, PartialEq, Eq)]
76enum Target {
77    /// Dial an explicit overlay IPv4 address and port via `tcp_connect`.
78    Ipv4(Ipv4Addr, u16),
79    /// Resolve a MagicDNS name and port, then dial via `connect_by_name`.
80    Domain(String, u16),
81}
82
83/// Parse a SOCKS5 request body `[VER, CMD, RSV, ATYP, DST.ADDR, DST.PORT]` into a [`Target`].
84///
85/// On any unsupported command or address type, returns `Err(rep)` with the SOCKS5 reply code the
86/// caller should send back before closing (`0x07` command-not-supported, `0x08`
87/// address-type-not-supported). A malformed/short buffer or a non-`0x05` version also maps to a
88/// reply code so the caller can respond rather than hang. IPv6 (`ATYP=0x04`) is refused — this fork
89/// is IPv4-only on the tailnet.
90fn parse_request(buf: &[u8]) -> Result<Target, u8> {
91    // Need at least VER, CMD, RSV, ATYP.
92    if buf.len() < 4 || buf[0] != SOCKS5_VER {
93        return Err(REP_CMD_NOT_SUPPORTED);
94    }
95    if buf[1] != CMD_CONNECT {
96        // BIND / UDP ASSOCIATE are not supported (TCP + IPv4 overlay only).
97        return Err(REP_CMD_NOT_SUPPORTED);
98    }
99    let atyp = buf[3];
100    match atyp {
101        ATYP_IPV4 => {
102            // 4 octets + 2-byte port.
103            if buf.len() < 4 + 4 + 2 {
104                return Err(REP_CMD_NOT_SUPPORTED);
105            }
106            let ip = Ipv4Addr::new(buf[4], buf[5], buf[6], buf[7]);
107            let port = u16::from_be_bytes([buf[8], buf[9]]);
108            Ok(Target::Ipv4(ip, port))
109        }
110        ATYP_DOMAIN => {
111            // 1-byte length, that many name bytes, then a 2-byte port.
112            if buf.len() < 5 {
113                return Err(REP_CMD_NOT_SUPPORTED);
114            }
115            let len = buf[4] as usize;
116            if buf.len() < 5 + len + 2 {
117                return Err(REP_CMD_NOT_SUPPORTED);
118            }
119            let host = match std::str::from_utf8(&buf[5..5 + len]) {
120                Ok(h) => h.to_owned(),
121                Err(_) => return Err(REP_CMD_NOT_SUPPORTED),
122            };
123            let port = u16::from_be_bytes([buf[5 + len], buf[6 + len]]);
124            Ok(Target::Domain(host, port))
125        }
126        ATYP_IPV6 => Err(REP_ATYP_NOT_SUPPORTED),
127        _ => Err(REP_ATYP_NOT_SUPPORTED),
128    }
129}
130
131/// Owned, cloneable dialer captured by the accept loop so it never holds `&Device`.
132///
133/// Holds only `Clone`/`Arc` pieces of the [`crate::Device`]: a clone of the netstack command
134/// [`Channel`], the device's own overlay IPv4 (fetched once before spawning), and a boxed
135/// [`Resolver`] closure. It replicates the small `Device::tcp_connect` logic so each spliced
136/// connection egresses over the overlay only — no `&Device` ever escapes.
137#[derive(Clone)]
138pub(crate) struct OverlayDialer {
139    channel: Channel,
140    self_ipv4: Ipv4Addr,
141    resolve: Resolver,
142}
143
144impl OverlayDialer {
145    /// Dial an explicit overlay IPv4 address (the SOCKS5 `ATYP=IPv4` path).
146    ///
147    /// Mirrors [`crate::Device::tcp_connect`]: binds an ephemeral overlay source port on this
148    /// device's own tailnet IPv4 and connects to `(addr, port)` over the netstack.
149    async fn dial_ipv4(
150        &self,
151        addr: Ipv4Addr,
152        port: u16,
153    ) -> Result<crate::netstack::TcpStream, Error> {
154        // TODO(npry): collision checking (matches Device::tcp_connect).
155        let ephemeral_port = rand::random_range(49152..=u16::MAX);
156        self.channel
157            .tcp_connect((self.self_ipv4, ephemeral_port).into(), (addr, port).into())
158            .await
159            .map_err(Into::into)
160    }
161
162    /// Resolve a MagicDNS `name` to a tailnet IPv4 and dial it (the SOCKS5 `ATYP=DOMAINNAME` path).
163    ///
164    /// Mirrors [`crate::Device::connect_by_name`]: an in-process netmap lookup via the captured
165    /// [`Resolver`], then a `tcp_connect` into the overlay. Returns
166    /// [`InternalErrorKind::BadRequest`] if the name does not resolve.
167    async fn dial_name(&self, name: &str, port: u16) -> Result<crate::netstack::TcpStream, Error> {
168        let addr = (self.resolve)(name.to_string())
169            .await?
170            .ok_or(Error::Internal(InternalErrorKind::BadRequest))?;
171        self.dial_ipv4(addr, port).await
172    }
173
174    /// Dial the parsed [`Target`] into the overlay.
175    async fn dial(&self, target: &Target) -> Result<crate::netstack::TcpStream, Error> {
176        match target {
177            Target::Ipv4(addr, port) => self.dial_ipv4(*addr, *port).await,
178            Target::Domain(host, port) => self.dial_name(host, *port).await,
179        }
180    }
181}
182
183/// RAII handle for a running loopback SOCKS5 proxy (mirrors `tsnet`'s loopback teardown).
184///
185/// Dropping the handle aborts the **accept loop** so no new connections are accepted; in-flight
186/// spliced connections continue until they close on their own, which is acceptable (the proxy is
187/// loopback-only and each connection already egresses over the overlay). Call [`Self::shutdown`] to
188/// stop it explicitly, or just drop it.
189///
190/// Lifecycle: this handle is **not** tied to [`crate::Device`] shutdown. If the caller drops the
191/// `Device` but keeps (or leaks) this handle, the accept loop and the bound `127.0.0.1` port stay
192/// alive until the handle drops. Hold the handle for exactly as long as you want the proxy and drop
193/// it (or call [`Self::shutdown`]) when done; do not let it outlive the `Device` it proxies into
194/// (dialing into a shut-down device's overlay just fails).
195#[must_use = "dropping the handle stops the loopback SOCKS5 proxy"]
196pub struct LoopbackHandle {
197    accept_task: AbortHandle,
198}
199
200impl LoopbackHandle {
201    /// Explicitly stop the loopback SOCKS5 proxy now. Equivalent to dropping the handle.
202    pub fn shutdown(self) {
203        // Drop runs the abort.
204    }
205}
206
207impl Drop for LoopbackHandle {
208    fn drop(&mut self) {
209        self.accept_task.abort();
210    }
211}
212
213impl OverlayDialer {
214    /// Build the dialer from the cloneable pieces of a [`crate::Device`]: a clone of the netstack
215    /// command [`Channel`], the device's own overlay IPv4, and a boxed [`Resolver`]. No `&Device` is
216    /// retained.
217    pub(crate) fn new(channel: Channel, self_ipv4: Ipv4Addr, resolve: Resolver) -> Self {
218        Self {
219            channel,
220            self_ipv4,
221            resolve,
222        }
223    }
224}
225
226/// Start the loopback SOCKS5 proxy. Called by [`crate::Device::loopback`].
227///
228/// Binds a TCP listener on `127.0.0.1:0` (host loopback only), generates a 32-char hex credential,
229/// and spawns the accept loop. Returns the bound address, the credential, and the [`LoopbackHandle`].
230pub(crate) async fn start(
231    dialer: OverlayDialer,
232) -> Result<(SocketAddr, String, LoopbackHandle), Error> {
233    // Bind ONLY host loopback (127.0.0.1) — never 0.0.0.0 or any external interface. The proxy is
234    // reachable solely from the local host, and every connection egresses over the overlay.
235    let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
236        .await
237        .map_err(|_| Error::Internal(InternalErrorKind::Io))?;
238    let local_addr = listener
239        .local_addr()
240        .map_err(|_| Error::Internal(InternalErrorKind::Io))?;
241
242    let cred = gen_cred();
243    let accept_cred = cred.clone();
244    let task = tokio::spawn(async move {
245        accept_loop(listener, dialer, accept_cred).await;
246    });
247
248    Ok((
249        local_addr,
250        cred,
251        LoopbackHandle {
252            accept_task: task.abort_handle(),
253        },
254    ))
255}
256
257/// Generate a 16-byte random credential rendered as 32 lowercase-hex chars (no new dependency).
258fn gen_cred() -> String {
259    let b: [u8; 16] = rand::random();
260    b.iter().map(|x| format!("{x:02x}")).collect()
261}
262
263/// Cap on simultaneous loopback SOCKS5 connections. This is a `127.0.0.1`-only debug/proxy
264/// listener, but each accepted connection dials INTO the overlay and so pins one netstack TCP socket
265/// (~512 KiB of rx+tx buffers, see `tcp_buffer_size` in AGENTS.md). 256 ≈ a 128 MB ceiling — enough
266/// for any realistic local client, while preventing a misbehaving local process from opening
267/// unbounded overlay sockets and exhausting memory. At the cap the accept loop back-pressures
268/// (stops accepting) until an in-flight connection finishes, which is the desired behavior here.
269const MAX_CONCURRENT_CONNS: usize = 256;
270
271/// Accept loop: one task per connection, capped at [`MAX_CONCURRENT_CONNS`] in flight. Aborting this
272/// task (via [`LoopbackHandle`]) stops accepting new connections; already-spawned connection tasks
273/// keep running until they finish.
274async fn accept_loop(listener: TcpListener, dialer: OverlayDialer, cred: String) {
275    let sem = Arc::new(Semaphore::new(MAX_CONCURRENT_CONNS));
276    loop {
277        // Acquire a permit BEFORE accepting so that at the cap the loop stops pulling new
278        // connections off the listener until an in-flight one finishes (back-pressure).
279        let permit = match sem.clone().acquire_owned().await {
280            Ok(permit) => permit,
281            // The semaphore is never closed in this loop; if it somehow is, stop accepting.
282            Err(_) => return,
283        };
284        let (sock, _peer) = match listener.accept().await {
285            Ok(pair) => pair,
286            Err(e) => {
287                tracing::warn!(error = %e, "loopback SOCKS5 accept failed; stopping accept loop");
288                return;
289            }
290        };
291        let dialer = dialer.clone();
292        let cred = cred.clone();
293        tokio::spawn(async move {
294            // Hold the permit for the lifetime of the connection; dropping it on task end frees
295            // the slot for the next accept.
296            let _permit = permit;
297            if let Err(e) = handle_conn(sock, dialer, cred).await {
298                tracing::debug!(error = %e, "loopback SOCKS5 connection ended");
299            }
300        });
301    }
302}
303
304/// Serve one SOCKS5 connection: negotiate (greeting, auth, CONNECT, overlay dial) under a bounded
305/// timeout, then splice without a deadline.
306///
307/// The negotiation phase is wrapped in [`HANDSHAKE_TIMEOUT`] so a local client that connects but
308/// never sends (or stalls mid-handshake) cannot park a task forever. The splice that follows has no
309/// timeout on purpose — a proxied connection is legitimately long-lived.
310async fn handle_conn(sock: TcpStream, dialer: OverlayDialer, cred: String) -> std::io::Result<()> {
311    let negotiated =
312        match tokio::time::timeout(HANDSHAKE_TIMEOUT, negotiate(sock, dialer, cred)).await {
313            Ok(res) => res?,
314            Err(_elapsed) => {
315                tracing::debug!("loopback SOCKS5 handshake timed out");
316                return Ok(());
317            }
318        };
319    // `None` means the handshake completed but the connection was rejected/closed (bad method, auth
320    // failure, unsupported request, or dial failure) — nothing left to splice.
321    let Some((mut sock, mut overlay)) = negotiated else {
322        return Ok(());
323    };
324
325    // Splice host socket <-> overlay stream (no deadline — proxied connections are long-lived).
326    match tokio::io::copy_bidirectional(&mut sock, &mut overlay).await {
327        Ok((to_overlay, to_host)) => {
328            tracing::debug!(to_overlay, to_host, "loopback SOCKS5 splice finished");
329        }
330        Err(e) => {
331            tracing::debug!(error = %e, "loopback SOCKS5 splice ended");
332        }
333    }
334    Ok(())
335}
336
337/// Negotiate one SOCKS5 connection up to (and including) the overlay dial. Returns
338/// `Ok(Some((client_socket, overlay_stream)))` ready to splice on success, or `Ok(None)` when the
339/// connection was cleanly rejected/closed during negotiation (bad version/method, auth failure,
340/// unsupported command/address type, or a dial failure — each already replied to the client).
341async fn negotiate(
342    mut sock: TcpStream,
343    dialer: OverlayDialer,
344    cred: String,
345) -> std::io::Result<Option<(TcpStream, crate::netstack::TcpStream)>> {
346    // 1) Greeting: [VER, NMETHODS, METHODS...].
347    let mut head = [0u8; 2];
348    sock.read_exact(&mut head).await?;
349    if head[0] != SOCKS5_VER {
350        return Ok(None);
351    }
352    let nmethods = head[1] as usize;
353    let mut methods = vec![0u8; nmethods];
354    sock.read_exact(&mut methods).await?;
355    if !methods.contains(&METHOD_USER_PASS) {
356        // No acceptable methods — we require username/password.
357        sock.write_all(&[SOCKS5_VER, METHOD_NONE]).await?;
358        return Ok(None);
359    }
360    sock.write_all(&[SOCKS5_VER, METHOD_USER_PASS]).await?;
361
362    // 2) RFC 1929 auth: [VER=0x01, ULEN, UNAME, PLEN, PASSWD].
363    let mut avh = [0u8; 2];
364    sock.read_exact(&mut avh).await?;
365    if avh[0] != AUTH_VER {
366        return Ok(None);
367    }
368    let ulen = avh[1] as usize;
369    let mut uname = vec![0u8; ulen];
370    sock.read_exact(&mut uname).await?;
371    let mut plh = [0u8; 1];
372    sock.read_exact(&mut plh).await?;
373    let plen = plh[0] as usize;
374    let mut passwd = vec![0u8; plen];
375    sock.read_exact(&mut passwd).await?;
376
377    let ok = uname.as_slice() == PROXY_USERNAME.as_bytes() && passwd.as_slice() == cred.as_bytes();
378    if !ok {
379        sock.write_all(&[AUTH_VER, 0x01]).await?; // auth failure
380        return Ok(None);
381    }
382    sock.write_all(&[AUTH_VER, 0x00]).await?; // auth success
383
384    // 3) Request: [VER, CMD, RSV, ATYP, DST.ADDR, DST.PORT].
385    let mut rh = [0u8; 4];
386    sock.read_exact(&mut rh).await?;
387    // Read the variable address + port into a single buffer so `parse_request` sees the full body.
388    let mut req = rh.to_vec();
389    match rh[3] {
390        ATYP_IPV4 => {
391            let mut rest = [0u8; 4 + 2];
392            sock.read_exact(&mut rest).await?;
393            req.extend_from_slice(&rest);
394        }
395        ATYP_DOMAIN => {
396            let mut lb = [0u8; 1];
397            sock.read_exact(&mut lb).await?;
398            let len = lb[0] as usize;
399            let mut rest = vec![0u8; len + 2];
400            sock.read_exact(&mut rest).await?;
401            req.push(lb[0]);
402            req.extend_from_slice(&rest);
403        }
404        ATYP_IPV6 => {
405            // Drain the 16-byte address + port so the peer isn't left mid-write, then refuse.
406            let mut rest = [0u8; 16 + 2];
407            drop(sock.read_exact(&mut rest).await);
408            reply_failure(&mut sock, REP_ATYP_NOT_SUPPORTED).await?;
409            return Ok(None);
410        }
411        _ => {
412            reply_failure(&mut sock, REP_ATYP_NOT_SUPPORTED).await?;
413            return Ok(None);
414        }
415    }
416
417    let target = match parse_request(&req) {
418        Ok(t) => t,
419        Err(rep) => {
420            reply_failure(&mut sock, rep).await?;
421            return Ok(None);
422        }
423    };
424
425    // 4) Dial INTO the overlay (never a host socket to the destination).
426    let overlay = match dialer.dial(&target).await {
427        Ok(s) => s,
428        Err(e) => {
429            tracing::debug!(?target, error = ?e, "loopback SOCKS5 overlay dial failed");
430            reply_failure(&mut sock, 0x05).await?; // connection refused
431            return Ok(None);
432        }
433    };
434
435    // Success reply: REP=0x00, ATYP=IPv4, bound addr 0.0.0.0:0 (conventional placeholder).
436    sock.write_all(&[SOCKS5_VER, 0x00, 0x00, ATYP_IPV4, 0, 0, 0, 0, 0, 0])
437        .await?;
438
439    Ok(Some((sock, overlay)))
440}
441
442/// Send a SOCKS5 failure reply with code `rep` (ATYP=IPv4, bound addr 0.0.0.0:0) and return.
443async fn reply_failure(sock: &mut TcpStream, rep: u8) -> std::io::Result<()> {
444    sock.write_all(&[SOCKS5_VER, rep, 0x00, ATYP_IPV4, 0, 0, 0, 0, 0, 0])
445        .await
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    #[test]
453    fn parse_request_ipv4() {
454        // CONNECT to 100.64.0.5:8080 (0x1f90).
455        let buf = [0x05, 0x01, 0x00, 0x01, 100, 64, 0, 5, 0x1f, 0x90];
456        let t = parse_request(&buf).expect("ipv4 target");
457        assert_eq!(t, Target::Ipv4(Ipv4Addr::new(100, 64, 0, 5), 8080));
458    }
459
460    #[test]
461    fn parse_request_domain() {
462        // 9-byte name "peer.host", port 443 (0x01bb).
463        let mut buf = vec![0x05, 0x01, 0x00, 0x03, 0x09];
464        buf.extend_from_slice(b"peer.host");
465        buf.extend_from_slice(&443u16.to_be_bytes());
466        let t = parse_request(&buf).expect("domain target");
467        assert_eq!(t, Target::Domain("peer.host".to_string(), 443));
468    }
469
470    #[test]
471    fn parse_request_ipv6_refused() {
472        // ATYP=0x04 (IPv6) -> address type not supported.
473        let mut buf = vec![0x05, 0x01, 0x00, 0x04];
474        buf.extend_from_slice(&[0u8; 16]); // address
475        buf.extend_from_slice(&443u16.to_be_bytes());
476        let rep = parse_request(&buf).expect_err("ipv6 refused");
477        assert_eq!(rep, REP_ATYP_NOT_SUPPORTED);
478    }
479
480    #[test]
481    fn parse_request_bad_cmd() {
482        // CMD=0x03 (UDP ASSOCIATE) -> command not supported.
483        let buf = [0x05, 0x03, 0x00, 0x01, 100, 64, 0, 5, 0x1f, 0x90];
484        let rep = parse_request(&buf).expect_err("bad cmd refused");
485        assert_eq!(rep, REP_CMD_NOT_SUPPORTED);
486    }
487
488    #[test]
489    fn hex_cred_len() {
490        let cred = gen_cred();
491        assert_eq!(cred.len(), 32);
492        assert!(
493            cred.chars()
494                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
495        );
496    }
497
498    // NOTE: a full end-to-end test (real SOCKS5 client through the proxy into a tailnet peer) needs
499    // a live overlay/netstack to dial; stubbing `OverlayDialer` would require generalizing the dial
500    // path over a trait purely for the test. We rely instead on the pure `parse_request` tests above
501    // plus the byte-layout reasoning in `handle_conn`.
502}