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}