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    /// Dial a `host`/`port` into the overlay, where `host` is either an IPv4 literal or a MagicDNS
183    /// name. The crate-visible entry point used by the `hyper` HTTP connector (which parses the
184    /// request `Uri` into host + port); an IPv4 literal skips the resolver, a name goes through it.
185    #[cfg(feature = "hyper")]
186    pub(crate) async fn dial_host_port(
187        &self,
188        host: &str,
189        port: u16,
190    ) -> Result<crate::netstack::TcpStream, Error> {
191        match host.parse::<Ipv4Addr>() {
192            Ok(addr) => self.dial_ipv4(addr, port).await,
193            Err(_) => self.dial_name(host, port).await,
194        }
195    }
196}
197
198/// RAII handle for a running loopback SOCKS5 proxy (mirrors `tsnet`'s loopback teardown).
199///
200/// Dropping the handle aborts the **accept loop** so no new connections are accepted; in-flight
201/// spliced connections continue until they close on their own, which is acceptable (the proxy is
202/// loopback-only and each connection already egresses over the overlay). Call [`Self::shutdown`] to
203/// stop it explicitly, or just drop it.
204///
205/// Lifecycle: this handle is **not** tied to [`crate::Device`] shutdown. If the caller drops the
206/// `Device` but keeps (or leaks) this handle, the accept loop and the bound `127.0.0.1` port stay
207/// alive until the handle drops. Hold the handle for exactly as long as you want the proxy and drop
208/// it (or call [`Self::shutdown`]) when done; do not let it outlive the `Device` it proxies into
209/// (dialing into a shut-down device's overlay just fails).
210#[must_use = "dropping the handle stops the loopback SOCKS5 proxy"]
211pub struct LoopbackHandle {
212    accept_task: AbortHandle,
213}
214
215impl LoopbackHandle {
216    /// Explicitly stop the loopback SOCKS5 proxy now. Equivalent to dropping the handle.
217    pub fn shutdown(self) {
218        // Drop runs the abort.
219    }
220}
221
222impl Drop for LoopbackHandle {
223    fn drop(&mut self) {
224        self.accept_task.abort();
225    }
226}
227
228impl OverlayDialer {
229    /// Build the dialer from the cloneable pieces of a [`crate::Device`]: a clone of the netstack
230    /// command [`Channel`], the device's own overlay IPv4, and a boxed [`Resolver`]. No `&Device` is
231    /// retained.
232    pub(crate) fn new(channel: Channel, self_ipv4: Ipv4Addr, resolve: Resolver) -> Self {
233        Self {
234            channel,
235            self_ipv4,
236            resolve,
237        }
238    }
239}
240
241/// Start the loopback SOCKS5 proxy. Called by [`crate::Device::loopback`].
242///
243/// Binds a TCP listener on `127.0.0.1:0` (host loopback only), generates a 32-char hex credential,
244/// and spawns the accept loop. Returns the bound address, the credential, and the [`LoopbackHandle`].
245pub(crate) async fn start(
246    dialer: OverlayDialer,
247) -> Result<(SocketAddr, String, LoopbackHandle), Error> {
248    // Bind ONLY host loopback (127.0.0.1) — never 0.0.0.0 or any external interface. The proxy is
249    // reachable solely from the local host, and every connection egresses over the overlay.
250    let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
251        .await
252        .map_err(|_| Error::Internal(InternalErrorKind::Io))?;
253    let local_addr = listener
254        .local_addr()
255        .map_err(|_| Error::Internal(InternalErrorKind::Io))?;
256
257    let cred = gen_cred();
258    let accept_cred = cred.clone();
259    let task = tokio::spawn(async move {
260        accept_loop(listener, dialer, accept_cred).await;
261    });
262
263    Ok((
264        local_addr,
265        cred,
266        LoopbackHandle {
267            accept_task: task.abort_handle(),
268        },
269    ))
270}
271
272/// Generate a 16-byte random credential rendered as 32 lowercase-hex chars (no new dependency).
273fn gen_cred() -> String {
274    let b: [u8; 16] = rand::random();
275    b.iter().map(|x| format!("{x:02x}")).collect()
276}
277
278/// Cap on simultaneous loopback SOCKS5 connections. This is a `127.0.0.1`-only debug/proxy
279/// listener, but each accepted connection dials INTO the overlay and so pins one netstack TCP socket
280/// (~512 KiB of rx+tx buffers, see `tcp_buffer_size` in AGENTS.md). 256 ≈ a 128 MB ceiling — enough
281/// for any realistic local client, while preventing a misbehaving local process from opening
282/// unbounded overlay sockets and exhausting memory. At the cap the accept loop back-pressures
283/// (stops accepting) until an in-flight connection finishes, which is the desired behavior here.
284const MAX_CONCURRENT_CONNS: usize = 256;
285
286/// Accept loop: one task per connection, capped at [`MAX_CONCURRENT_CONNS`] in flight. Aborting this
287/// task (via [`LoopbackHandle`]) stops accepting new connections; already-spawned connection tasks
288/// keep running until they finish.
289async fn accept_loop(listener: TcpListener, dialer: OverlayDialer, cred: String) {
290    let sem = Arc::new(Semaphore::new(MAX_CONCURRENT_CONNS));
291    loop {
292        // Acquire a permit BEFORE accepting so that at the cap the loop stops pulling new
293        // connections off the listener until an in-flight one finishes (back-pressure).
294        let permit = match sem.clone().acquire_owned().await {
295            Ok(permit) => permit,
296            // The semaphore is never closed in this loop; if it somehow is, stop accepting.
297            Err(_) => return,
298        };
299        let (sock, _peer) = match listener.accept().await {
300            Ok(pair) => pair,
301            Err(e) => {
302                tracing::warn!(error = %e, "loopback SOCKS5 accept failed; stopping accept loop");
303                return;
304            }
305        };
306        let dialer = dialer.clone();
307        let cred = cred.clone();
308        tokio::spawn(async move {
309            // Hold the permit for the lifetime of the connection; dropping it on task end frees
310            // the slot for the next accept.
311            let _permit = permit;
312            if let Err(e) = handle_conn(sock, dialer, cred).await {
313                tracing::debug!(error = %e, "loopback SOCKS5 connection ended");
314            }
315        });
316    }
317}
318
319/// Serve one SOCKS5 connection: negotiate (greeting, auth, CONNECT, overlay dial) under a bounded
320/// timeout, then splice without a deadline.
321///
322/// The negotiation phase is wrapped in [`HANDSHAKE_TIMEOUT`] so a local client that connects but
323/// never sends (or stalls mid-handshake) cannot park a task forever. The splice that follows has no
324/// timeout on purpose — a proxied connection is legitimately long-lived.
325async fn handle_conn(sock: TcpStream, dialer: OverlayDialer, cred: String) -> std::io::Result<()> {
326    let negotiated =
327        match tokio::time::timeout(HANDSHAKE_TIMEOUT, negotiate(sock, dialer, cred)).await {
328            Ok(res) => res?,
329            Err(_elapsed) => {
330                tracing::debug!("loopback SOCKS5 handshake timed out");
331                return Ok(());
332            }
333        };
334    // `None` means the handshake completed but the connection was rejected/closed (bad method, auth
335    // failure, unsupported request, or dial failure) — nothing left to splice.
336    let Some((mut sock, mut overlay)) = negotiated else {
337        return Ok(());
338    };
339
340    // Splice host socket <-> overlay stream (no deadline — proxied connections are long-lived).
341    match tokio::io::copy_bidirectional(&mut sock, &mut overlay).await {
342        Ok((to_overlay, to_host)) => {
343            tracing::debug!(to_overlay, to_host, "loopback SOCKS5 splice finished");
344        }
345        Err(e) => {
346            tracing::debug!(error = %e, "loopback SOCKS5 splice ended");
347        }
348    }
349    Ok(())
350}
351
352/// Negotiate one SOCKS5 connection up to (and including) the overlay dial. Returns
353/// `Ok(Some((client_socket, overlay_stream)))` ready to splice on success, or `Ok(None)` when the
354/// connection was cleanly rejected/closed during negotiation (bad version/method, auth failure,
355/// unsupported command/address type, or a dial failure — each already replied to the client).
356async fn negotiate(
357    mut sock: TcpStream,
358    dialer: OverlayDialer,
359    cred: String,
360) -> std::io::Result<Option<(TcpStream, crate::netstack::TcpStream)>> {
361    // 1) Greeting: [VER, NMETHODS, METHODS...].
362    let mut head = [0u8; 2];
363    sock.read_exact(&mut head).await?;
364    if head[0] != SOCKS5_VER {
365        return Ok(None);
366    }
367    let nmethods = head[1] as usize;
368    let mut methods = vec![0u8; nmethods];
369    sock.read_exact(&mut methods).await?;
370    if !methods.contains(&METHOD_USER_PASS) {
371        // No acceptable methods — we require username/password.
372        sock.write_all(&[SOCKS5_VER, METHOD_NONE]).await?;
373        return Ok(None);
374    }
375    sock.write_all(&[SOCKS5_VER, METHOD_USER_PASS]).await?;
376
377    // 2) RFC 1929 auth: [VER=0x01, ULEN, UNAME, PLEN, PASSWD].
378    let mut avh = [0u8; 2];
379    sock.read_exact(&mut avh).await?;
380    if avh[0] != AUTH_VER {
381        return Ok(None);
382    }
383    let ulen = avh[1] as usize;
384    let mut uname = vec![0u8; ulen];
385    sock.read_exact(&mut uname).await?;
386    let mut plh = [0u8; 1];
387    sock.read_exact(&mut plh).await?;
388    let plen = plh[0] as usize;
389    let mut passwd = vec![0u8; plen];
390    sock.read_exact(&mut passwd).await?;
391
392    let ok = uname.as_slice() == PROXY_USERNAME.as_bytes() && passwd.as_slice() == cred.as_bytes();
393    if !ok {
394        sock.write_all(&[AUTH_VER, 0x01]).await?; // auth failure
395        return Ok(None);
396    }
397    sock.write_all(&[AUTH_VER, 0x00]).await?; // auth success
398
399    // 3) Request: [VER, CMD, RSV, ATYP, DST.ADDR, DST.PORT].
400    let mut rh = [0u8; 4];
401    sock.read_exact(&mut rh).await?;
402    // Read the variable address + port into a single buffer so `parse_request` sees the full body.
403    let mut req = rh.to_vec();
404    match rh[3] {
405        ATYP_IPV4 => {
406            let mut rest = [0u8; 4 + 2];
407            sock.read_exact(&mut rest).await?;
408            req.extend_from_slice(&rest);
409        }
410        ATYP_DOMAIN => {
411            let mut lb = [0u8; 1];
412            sock.read_exact(&mut lb).await?;
413            let len = lb[0] as usize;
414            let mut rest = vec![0u8; len + 2];
415            sock.read_exact(&mut rest).await?;
416            req.push(lb[0]);
417            req.extend_from_slice(&rest);
418        }
419        ATYP_IPV6 => {
420            // Drain the 16-byte address + port so the peer isn't left mid-write, then refuse.
421            let mut rest = [0u8; 16 + 2];
422            drop(sock.read_exact(&mut rest).await);
423            reply_failure(&mut sock, REP_ATYP_NOT_SUPPORTED).await?;
424            return Ok(None);
425        }
426        _ => {
427            reply_failure(&mut sock, REP_ATYP_NOT_SUPPORTED).await?;
428            return Ok(None);
429        }
430    }
431
432    let target = match parse_request(&req) {
433        Ok(t) => t,
434        Err(rep) => {
435            reply_failure(&mut sock, rep).await?;
436            return Ok(None);
437        }
438    };
439
440    // 4) Dial INTO the overlay (never a host socket to the destination).
441    let overlay = match dialer.dial(&target).await {
442        Ok(s) => s,
443        Err(e) => {
444            tracing::debug!(?target, error = ?e, "loopback SOCKS5 overlay dial failed");
445            reply_failure(&mut sock, 0x05).await?; // connection refused
446            return Ok(None);
447        }
448    };
449
450    // Success reply: REP=0x00, ATYP=IPv4, bound addr 0.0.0.0:0 (conventional placeholder).
451    sock.write_all(&[SOCKS5_VER, 0x00, 0x00, ATYP_IPV4, 0, 0, 0, 0, 0, 0])
452        .await?;
453
454    Ok(Some((sock, overlay)))
455}
456
457/// Send a SOCKS5 failure reply with code `rep` (ATYP=IPv4, bound addr 0.0.0.0:0) and return.
458async fn reply_failure(sock: &mut TcpStream, rep: u8) -> std::io::Result<()> {
459    sock.write_all(&[SOCKS5_VER, rep, 0x00, ATYP_IPV4, 0, 0, 0, 0, 0, 0])
460        .await
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn parse_request_ipv4() {
469        // CONNECT to 100.64.0.5:8080 (0x1f90).
470        let buf = [0x05, 0x01, 0x00, 0x01, 100, 64, 0, 5, 0x1f, 0x90];
471        let t = parse_request(&buf).expect("ipv4 target");
472        assert_eq!(t, Target::Ipv4(Ipv4Addr::new(100, 64, 0, 5), 8080));
473    }
474
475    #[test]
476    fn parse_request_domain() {
477        // 9-byte name "peer.host", port 443 (0x01bb).
478        let mut buf = vec![0x05, 0x01, 0x00, 0x03, 0x09];
479        buf.extend_from_slice(b"peer.host");
480        buf.extend_from_slice(&443u16.to_be_bytes());
481        let t = parse_request(&buf).expect("domain target");
482        assert_eq!(t, Target::Domain("peer.host".to_string(), 443));
483    }
484
485    #[test]
486    fn parse_request_ipv6_refused() {
487        // ATYP=0x04 (IPv6) -> address type not supported.
488        let mut buf = vec![0x05, 0x01, 0x00, 0x04];
489        buf.extend_from_slice(&[0u8; 16]); // address
490        buf.extend_from_slice(&443u16.to_be_bytes());
491        let rep = parse_request(&buf).expect_err("ipv6 refused");
492        assert_eq!(rep, REP_ATYP_NOT_SUPPORTED);
493    }
494
495    #[test]
496    fn parse_request_bad_cmd() {
497        // CMD=0x03 (UDP ASSOCIATE) -> command not supported.
498        let buf = [0x05, 0x03, 0x00, 0x01, 100, 64, 0, 5, 0x1f, 0x90];
499        let rep = parse_request(&buf).expect_err("bad cmd refused");
500        assert_eq!(rep, REP_CMD_NOT_SUPPORTED);
501    }
502
503    #[test]
504    fn hex_cred_len() {
505        let cred = gen_cred();
506        assert_eq!(cred.len(), 32);
507        assert!(
508            cred.chars()
509                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
510        );
511    }
512
513    // NOTE: a full end-to-end test (real SOCKS5 client through the proxy into a tailnet peer) needs
514    // a live overlay/netstack to dial; stubbing `OverlayDialer` would require generalizing the dial
515    // path over a trait purely for the test. We rely instead on the pure `parse_request` tests above
516    // plus the byte-layout reasoning in `handle_conn`.
517}