Skip to main content

ts_runtime/
funnel.rs

1//! Client-side Funnel **ingress** termination (`tsnet`'s `ListenFunnel` data path).
2//!
3//! ## The model (Go `ipn/ipnlocal/serve.go`'s `handleIngress` / `TCPHandlerForFunnelFlow`)
4//!
5//! Public Funnel traffic does not reach this node directly. Tailscale operates a public **ingress
6//! relay** (a tailnet peer, provisioned by control when a node advertises `HostInfo.IngressEnabled`)
7//! plus the public DNS `<node>.<tailnet>.ts.net:443` → relay mapping. A public client's TLS bytes
8//! arrive at the relay, which opens a connection to this node's **peerAPI** and `POST`s
9//! `/v0/ingress` with the headers `Tailscale-Ingress-Src` (the public client `host:port`,
10//! informational) and `Tailscale-Ingress-Target` (the `host:port` the client hit). The node replies
11//! `HTTP/1.1 101 Switching Protocols\r\n\r\n` to **hijack** the connection into a raw bidirectional
12//! stream that now carries the public client's TLS handshake + records. The node then TLS-terminates
13//! that stream with its own `*.ts.net` certificate (the Funnel hostname *is* the node's MagicDNS
14//! name) and serves the decrypted stream.
15//!
16//! This module is the node-side half: the [`FunnelManager`](crate::funnel::FunnelManager) holds the node's `TlsAcceptor` and an
17//! `mpsc::Sender` sink ([`FunnelIngressSink`](crate::funnel::FunnelIngressSink)) the peerAPI `/v0/ingress` handler pushes hijacked
18//! raw streams to. A spawned pump task TLS-terminates each raw stream and yields the decrypted
19//! [`FunnelAccepted`](crate::funnel::FunnelAccepted) over a [`FunnelAcceptedReceiver`](crate::funnel::FunnelAcceptedReceiver) the embedder holds (the in-process stand-in
20//! for Go `tsnet`'s `ListenFunnel`-returned `net.Listener`).
21//!
22//! The relay + DNS legs are **Tailscale infrastructure** — present against real Tailscale SaaS (with
23//! a Funnel-enabled ACL), absent against a self-hosted control plane. So this code is
24//! correct and fully wired, but only ever fed when the node talks to real Tailscale.
25//!
26//! ## Anti-leak
27//!
28//! The hijacked ingress stream arrives on the **overlay** peerAPI listener (the netstack
29//! `OverlayStream`, never a host socket). TLS is terminated on that overlay stream and the
30//! decrypted stream is handed to the embedder. Nothing here ever dials a host socket and nothing
31//! routes through the `ts_forwarder` exit-egress path — Funnel ingress is purely inbound overlay
32//! traffic, structurally separate from the exit-node anti-leak chokepoint. There is no plaintext
33//! downgrade: if TLS termination fails, the connection is dropped (logged).
34
35use std::sync::Arc;
36
37use netstack::netsock::TcpStream as OverlayStream;
38use tokio::{
39    io::{AsyncRead, AsyncWrite},
40    sync::mpsc,
41};
42use ts_control::tls::TlsAcceptor;
43
44use crate::serve::AsyncReadWrite;
45
46/// Bound on hijacked-but-not-yet-TLS-terminated ingress connections queued to the pump task, and on
47/// TLS-terminated connections queued to the embedder. A relay flood back-pressures the peerAPI
48/// `/v0/ingress` handler (which then drops, fail-closed) rather than buffering without limit. Each
49/// queued conn pins an overlay TCP socket (~512 KiB rx+tx buffers — see `tcp_buffer_size` in
50/// AGENTS.md), so the cap is deliberately modest.
51const MAX_INGRESS_INFLIGHT: usize = 256;
52
53/// A raw (not-yet-TLS-terminated) Funnel ingress connection the peerAPI `/v0/ingress` handler
54/// hijacked off the relay's POST and handed to the [`FunnelManager`]'s sink.
55///
56/// `stream` is the overlay peerAPI connection *after* the `HTTP/1.1 101 Switching Protocols` reply —
57/// raw bytes from here on are the public client's TLS handshake + records. `target` is the
58/// `Tailscale-Ingress-Target` (`host:port` the public client hit) and `src` the
59/// `Tailscale-Ingress-Src` (the public client's `host:port`, informational), both parsed from the
60/// POST headers.
61pub struct IngressConn {
62    /// The `Tailscale-Ingress-Target` header — the `host:port` the public client connected to.
63    pub target: String,
64    /// The `Tailscale-Ingress-Src` header — the public client's `host:port` (informational).
65    pub src: String,
66    /// The hijacked raw overlay stream carrying the public client's TLS, post-101.
67    pub stream: OverlayStream,
68}
69
70/// The sink the peerAPI `/v0/ingress` handler pushes hijacked [`IngressConn`]s to. Cloneable; an
71/// `mpsc::Sender` so the handler back-pressures (and then drops, fail-closed) when the pump can't
72/// keep up. Installed into the peerAPI server via the shared slot (see [`FunnelIngressSlot`]) when
73/// the embedder calls `Device::listen_funnel`.
74pub type FunnelIngressSink = mpsc::Sender<IngressConn>;
75
76/// The shared, runtime-lifetime slot the peerAPI server reads per connection to find the active
77/// [`FunnelIngressSink`], and that `Device::listen_funnel` writes when it stands up a
78/// [`FunnelManager`]. `None` (the default) means no funnel listener is active, so the peerAPI
79/// `/v0/ingress` route fails closed (`404`) without hijacking. The peerAPI server (spawned at
80/// runtime start, before any `listen_funnel`) holds a clone of this `Arc`; installing a sink at
81/// `listen_funnel` time makes the route live without restarting the server.
82pub type FunnelIngressSlot = Arc<std::sync::Mutex<Option<FunnelIngressSink>>>;
83
84/// A fully TLS-terminated Funnel ingress connection handed back to the embedder (the in-process
85/// stand-in for Go `tsnet`'s `ListenFunnel`-returned `net.Listener`).
86///
87/// `stream` is the decrypted stream (the overlay stream wrapped in `tokio_rustls`'s server
88/// `TlsStream`, boxed so the type is target-agnostic). `target`/`src` carry the ingress headers
89/// through so an embedder can route on the hit `host:port` and log the public client.
90pub struct FunnelAccepted {
91    /// The `Tailscale-Ingress-Target` (`host:port` the public client hit).
92    pub target: String,
93    /// The `Tailscale-Ingress-Src` (the public client's `host:port`, informational).
94    pub src: String,
95    /// The accepted, TLS-terminated stream, ready to read/write.
96    pub stream: Box<dyn AsyncReadWrite>,
97}
98
99/// Receiver side of the Funnel ingress hand-back channel (mirrors a `net.Listener`'s accept queue).
100/// `Device::listen_funnel` returns one; await [`recv`](mpsc::Receiver::recv) to take the next
101/// TLS-terminated public connection. Dropping it (or dropping the [`FunnelManager`]) tears the
102/// listener down.
103pub type FunnelAcceptedReceiver = mpsc::Receiver<FunnelAccepted>;
104
105/// Owns the node's Funnel ingress data path: the `TlsAcceptor` built from the node's `*.ts.net`
106/// cert and the pump task that TLS-terminates each hijacked [`IngressConn`].
107///
108/// Built by `Device::listen_funnel` after the [`funnel_access`](ts_control::funnel_access) gate and
109/// cert path pass. Holds the sink end so the manager keeps the channel (and thus the route) alive;
110/// dropping the manager closes the sink and stops the pump. Registered on the device (mirroring
111/// `serve: Mutex<Option<ServeManager>>`) so its lifetime is tied to the `Device`.
112pub struct FunnelManager {
113    /// Kept so the [`FunnelIngressSink`] installed in the shared slot stays valid for the manager's
114    /// life; dropping the manager drops this, closing the channel and ending the pump task.
115    _ingress_tx: FunnelIngressSink,
116    /// Aborts the TLS-termination pump task when the manager drops.
117    pump: tokio::task::AbortHandle,
118}
119
120impl Drop for FunnelManager {
121    fn drop(&mut self) {
122        self.pump.abort();
123    }
124}
125
126impl FunnelManager {
127    /// Build a manager from the node's `acceptor` (made from its `*.ts.net` cert), returning the
128    /// manager, the [`FunnelIngressSink`] to install into the peerAPI [`FunnelIngressSlot`], and the
129    /// [`FunnelAcceptedReceiver`] handed back to the embedder.
130    ///
131    /// Spawns the pump task: for each hijacked [`IngressConn`] it TLS-terminates the raw overlay
132    /// stream with `acceptor` and forwards a [`FunnelAccepted`] to the embedder. A TLS handshake
133    /// failure drops that connection (fail-closed, logged) and the pump continues. The pump ends when
134    /// the sink is dropped (manager dropped) or the embedder drops the receiver.
135    pub fn new(acceptor: TlsAcceptor) -> (Self, FunnelIngressSink, FunnelAcceptedReceiver) {
136        let (ingress_tx, ingress_rx) = mpsc::channel::<IngressConn>(MAX_INGRESS_INFLIGHT);
137        let (accept_tx, accept_rx) = mpsc::channel::<FunnelAccepted>(MAX_INGRESS_INFLIGHT);
138
139        let pump = tokio::spawn(run_pump(acceptor, ingress_rx, accept_tx)).abort_handle();
140
141        (
142            Self {
143                _ingress_tx: ingress_tx.clone(),
144                pump,
145            },
146            ingress_tx,
147            accept_rx,
148        )
149    }
150}
151
152/// TLS-terminate each hijacked ingress stream and hand the decrypted stream to the embedder.
153///
154/// One handshake per connection, spawned so a slow handshake on one public client can't head-of-line
155/// block another. A handshake failure drops the connection (fail-closed, logged). Ends when
156/// `ingress_rx` closes (sink dropped) or `accept_tx` closes (embedder dropped the receiver).
157async fn run_pump(
158    acceptor: TlsAcceptor,
159    mut ingress_rx: mpsc::Receiver<IngressConn>,
160    accept_tx: mpsc::Sender<FunnelAccepted>,
161) {
162    while let Some(conn) = ingress_rx.recv().await {
163        let acceptor = acceptor.clone();
164        let accept_tx = accept_tx.clone();
165        tokio::spawn(async move {
166            terminate_and_forward(&acceptor, conn, &accept_tx).await;
167        });
168    }
169}
170
171/// Terminate TLS on one hijacked ingress stream and forward the decrypted stream. Anti-leak: TLS is
172/// terminated on the overlay stream (never a host socket); no plaintext downgrade — a handshake
173/// failure drops the connection.
174async fn terminate_and_forward(
175    acceptor: &TlsAcceptor,
176    conn: IngressConn,
177    accept_tx: &mpsc::Sender<FunnelAccepted>,
178) {
179    let IngressConn {
180        target,
181        src,
182        stream,
183    } = conn;
184    let tls = match acceptor.accept(stream).await {
185        Ok(s) => s,
186        Err(e) => {
187            tracing::debug!(%target, %src, error = %e, "funnel ingress: TLS handshake failed; dropping conn");
188            return;
189        }
190    };
191    let accepted = FunnelAccepted {
192        target,
193        src,
194        stream: Box::new(tls),
195    };
196    if accept_tx.send(accepted).await.is_err() {
197        tracing::debug!("funnel ingress: accept receiver dropped; closing conn");
198    }
199}
200
201/// Assert that `S` is an `AsyncRead + AsyncWrite` so callers know the decrypted stream is drivable.
202#[allow(dead_code)]
203fn _assert_accepted_is_io<S: AsyncRead + AsyncWrite>() {}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn inflight_cap_is_bounded() {
211        assert_eq!(MAX_INGRESS_INFLIGHT, 256);
212    }
213
214    // NOTE: a live TLS-termination test needs both a `TlsAcceptor` (a real `CertifiedKey`, which
215    // `ts_control::serve` already exercises via `tls_acceptor_builds_from_certified_key`) and an
216    // `OverlayStream` (constructible only with a live netstack channel). `ts_runtime` carries no
217    // `rcgen` dev-dep and cannot build either in isolation, so — like the netstack-backed managers
218    // (`serve`/`fallback_tcp`) — the pump's accept/forward path is left to integration coverage
219    // (`Device::listen_funnel` against a real device). The pure pieces (the inflight cap here, the
220    // ingress header parse + 101-response bytes + route classification in `peerapi`) are unit-tested.
221}