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}