Skip to main content

tailscale/ssh/
channel_server.rs

1use std::{collections::HashMap, marker::PhantomData, net::SocketAddr, sync::Arc};
2
3use russh::{
4    Channel, ChannelId, ChannelOpenFailure, Pty, Sig,
5    server::{Auth, ChannelOpenHandle, Handle, Msg, Session},
6};
7use tokio::{
8    sync::{mpsc, mpsc::UnboundedSender},
9    task::JoinSet,
10};
11
12use crate::{
13    Device,
14    ssh::{SshAccept, TailnetServer},
15};
16
17type Request = (ChannelId, ChannelEvent);
18
19/// Handler for a channel session.
20pub trait ChannelHandler: Sized {
21    /// Error this handler produces.
22    type Error: Into<std::io::Error> + std::error::Error;
23
24    /// Construct a new per-channel handler.
25    ///
26    /// `accept` is the [`SshAccept`] produced by the single fail-closed authorization decision in
27    /// [`auth_none`][russh::server::Handler::auth_none]; in particular its
28    /// [`local_user`][SshAccept::local_user] is the policy-mapped identity the session must run as.
29    /// Handlers MUST NOT re-evaluate policy or substitute a different user — the accepted identity
30    /// is the sole authorization source.
31    fn new(
32        handle: tokio::runtime::Handle,
33        channel_id: ChannelId,
34        session: Handle,
35        dev: Arc<Device>,
36        accept: &SshAccept,
37    ) -> Result<Self, Self::Error>;
38
39    /// Handle an event from the channel.
40    fn handle_event(
41        &mut self,
42        event: &ChannelEvent,
43    ) -> impl Future<Output = Result<(), Self::Error>> + Send;
44}
45
46/// Implementation of [`russh::server::Handler`] which provides per-channel session
47/// handlers using a parametric [`ChannelHandler`].
48///
49/// Primary motivation is to support custom console or TUI sessions over tailnet SSH
50/// connections.
51///
52/// # Authentication and authorization
53///
54/// Incoming connections are gated by the control-pushed Tailscale SSH policy: [`auth_none`]
55/// resolves the source IP to a known tailnet peer and evaluates the policy via
56/// [`Device::authorize_ssh`][crate::Device::authorize_ssh] (fail-closed — an unknown peer, an
57/// absent policy, or a non-matching policy all reject). The `ssh` policy block's accept/reject
58/// rules, principal matching, and SSH-user mapping are honored. A rule that **demands** session
59/// recording (non-empty `recorders`) or `holdAndDelegate` is enforced **fail-closed**: since this
60/// fork has no recorder transport / delegate round-trip yet, such a session is refused rather than
61/// silently accepted un-recorded (see [`auth_none`]). Building those transports is deferred.
62///
63/// [`auth_none`]: russh::server::Handler::auth_none
64pub struct ChannelServer<H> {
65    channel_state: HashMap<ChannelId, ChannelState>,
66    remote: SocketAddr,
67    dev: Arc<Device>,
68    /// The accepted identity from the single [`auth_none`][russh::server::Handler::auth_none]
69    /// authorization decision, stashed so per-channel handlers run as the policy-mapped user.
70    /// `None` until a successful `auth_none`; a channel open with `None` here fails closed.
71    accepted: Option<SshAccept>,
72    _handler: PhantomSend<H>,
73}
74
75struct PhantomSend<H>(PhantomData<fn() -> H>);
76
77/// Maximum number of concurrent channels a single SSH connection may open. Each channel spawns a
78/// session handler (e.g. a login shell), so this caps the per-connection resource/process fan-out
79/// an authorized-but-hostile peer can induce. SSH clients realistically open one (or a few)
80/// sessions per connection, so this is generous for legitimate use.
81const MAX_CHANNELS_PER_CONN: usize = 16;
82
83/// Whether a connection at `open_channels` currently-open channels has reached the per-connection
84/// channel cap and must refuse the next channel open. Pure boundary predicate extracted from
85/// [`ChannelServer::channel_open_session`] so the fork-bomb guard's edge can be unit-tested without
86/// a live russh [`Session`].
87fn at_channel_cap(open_channels: usize) -> bool {
88    open_channels >= MAX_CHANNELS_PER_CONN
89}
90
91/// Fallback message logged when a `recording_required` session is refused and the policy supplied
92/// no message of its own.
93const DEFAULT_RECORDING_REFUSAL: &str =
94    "policy requires session recording but recording is not available";
95
96/// The fail-closed recording gate (tsr-0h2), extracted as a pure predicate so it can be unit-tested
97/// without a live russh [`Session`]/[`Device`] (mirrors [`at_channel_cap`]).
98///
99/// Returns `Some(message)` when the accepted session must be **refused** because the matched rule
100/// demands a capability this fork cannot provide — session recording (non-empty `recorders`) or a
101/// `holdAndDelegate` decision (both surfaced as [`SshAccept::recording_required`]) — and there is no
102/// recorder/delegate transport yet. The message is the policy's
103/// [`recording_refusal_message`][crate::ssh::SshAccept::recording_refusal_message] when non-empty,
104/// else [`DEFAULT_RECORDING_REFUSAL`]. Returns `None` for the common case (no recorders, no
105/// delegate), so those sessions accept unchanged.
106///
107/// TODO(tsr-0h2 follow-up): once the recorder stream transport exists (dial `recorders`, asciinema/
108/// CastV2 stream, tee PTY I/O at `shell.rs`) — and a Noise control round-trip backs `holdAndDelegate`
109/// — relax this to Go `tailssh`'s true default: fail-OPEN on a recorder-connect failure UNLESS
110/// `on_recording_failure.reject_session_with_message` is set. Until then, refuse rather than record
111/// nothing.
112fn recording_refusal(accept: &SshAccept) -> Option<String> {
113    if !accept.recording_required {
114        return None;
115    }
116    if accept.recording_refusal_message.is_empty() {
117        Some(DEFAULT_RECORDING_REFUSAL.to_string())
118    } else {
119        Some(accept.recording_refusal_message.clone())
120    }
121}
122
123#[derive(thiserror::Error, Debug, Copy, Clone, PartialEq, Eq)]
124#[error("no such channel")]
125struct NoChannel;
126
127/// State of a channel in [`ChannelServer`].
128struct ChannelState {
129    channel: ChannelId,
130    tx: UnboundedSender<Request>,
131    _joinset: JoinSet<()>,
132}
133
134impl ChannelState {
135    fn send(&self, event: ChannelEvent) {
136        if self.tx.send((self.channel, event)).is_err() {
137            tracing::error!(channel = %self.channel, "failed to send event");
138        }
139    }
140}
141
142impl<H> ChannelServer<H> {
143    fn get_channel(
144        &mut self,
145        id: ChannelId,
146    ) -> Result<&mut ChannelState, Box<dyn std::error::Error + Send + Sync + 'static>> {
147        self.channel_state.get_mut(&id).ok_or(Box::new(NoChannel))
148    }
149}
150
151impl<H> TailnetServer for ChannelServer<H> {
152    fn new_client(dev: Arc<Device>, addr: SocketAddr) -> Self {
153        Self {
154            channel_state: Default::default(),
155            dev,
156            remote: addr,
157            accepted: None,
158            _handler: PhantomSend(PhantomData),
159        }
160    }
161}
162
163/// An event that may be generated by a channel connected to a [`ChannelServer`].
164#[derive(Debug, Clone)]
165pub enum ChannelEvent {
166    /// Data was received over the channel.
167    Data(Vec<u8>),
168    /// A resize event occurred.
169    Resize {
170        /// The new width of the tty.
171        width: u16,
172        /// The new height of the tty.
173        height: u16,
174    },
175    /// A signal was sent over the channel.
176    Signal(Sig),
177    /// The channel was closed.
178    Close,
179    /// The channel received EOF.
180    Eof,
181}
182
183impl<H> russh::server::Handler for ChannelServer<H>
184where
185    H: ChannelHandler + Send,
186    H::Error: Send,
187{
188    type Error = Box<dyn std::error::Error + Send + Sync + 'static>;
189
190    #[tracing::instrument(skip_all, fields(user = %user, remote = ?self.remote))]
191    async fn auth_none(&mut self, user: &str) -> Result<Auth, Self::Error> {
192        // Enforce the control-pushed Tailscale SSH policy. Fail-closed: an unknown source, an
193        // absent policy, a non-matching policy, or any lookup error all reject the connection.
194        match self.dev.authorize_ssh(self.remote, user).await {
195            Ok(crate::ssh::SshDecision::Accept(accept)) => {
196                // SECURITY (tsr-0h2): a matched rule that DEMANDS session recording (non-empty
197                // `recorders`) — or a `holdAndDelegate` decision — cannot be honored because this
198                // fork has no recorder transport / delegate round-trip yet. Refuse the session
199                // (fail-closed) rather than silently downgrade it to a plain accept. This mirrors
200                // Go `tailssh`'s posture when `OnRecordingFailure.RejectSessionWithMessage` is set.
201                // `Auth::reject()` (the SSH `none`-method rejection) carries no client-visible
202                // message, so the policy's refusal message is surfaced in the warning log.
203                if let Some(msg) = recording_refusal(&accept) {
204                    tracing::warn!(
205                        local_user = %accept.local_user,
206                        recorders = ?accept.recorders,
207                        message = %msg,
208                        "ssh: session refused: policy requires session recording but recording is not available"
209                    );
210                    return Ok(Auth::reject());
211                }
212                tracing::debug!(
213                    local_user = %accept.local_user,
214                    "ssh: policy accepted connection"
215                );
216                // Stash the accepted identity so the per-channel handler runs as the
217                // policy-mapped local user. This is the single fail-closed authorization point;
218                // the handler never re-evaluates policy.
219                self.accepted = Some(accept);
220                Ok(Auth::Accept)
221            }
222            Ok(crate::ssh::SshDecision::Deny(reason)) => {
223                tracing::warn!(?reason, "ssh: policy denied connection");
224                Ok(Auth::reject())
225            }
226            Err(e) => {
227                tracing::error!(error = %e, "ssh: authorization failed; rejecting");
228                Ok(Auth::reject())
229            }
230        }
231    }
232
233    async fn channel_open_session(
234        &mut self,
235        channel: Channel<Msg>,
236        reply: ChannelOpenHandle,
237        session: &mut Session,
238    ) -> Result<(), Self::Error> {
239        tracing::debug!(channel = ?channel.id(), "new session");
240
241        // Fail closed: a channel open must be preceded by a successful `auth_none` that stashed
242        // the accepted identity. If it is somehow absent, refuse to open the channel rather than
243        // run a handler with no authorized user.
244        let Some(accept) = self.accepted.clone() else {
245            tracing::error!(
246                channel = ?channel.id(),
247                "ssh: channel open with no accepted identity; refusing"
248            );
249            reply
250                .reject(ChannelOpenFailure::AdministrativelyProhibited)
251                .await;
252            return Ok(());
253        };
254
255        // Bound the number of concurrent channels (each opens a session/handler — e.g. a login
256        // shell). Without this an authorized-but-hostile peer could open unbounded channels on one
257        // connection and fork-bomb the host with session handlers. Past the cap, refuse new channels.
258        if at_channel_cap(self.channel_state.len()) {
259            tracing::warn!(
260                channel = ?channel.id(),
261                cap = MAX_CHANNELS_PER_CONN,
262                "ssh: per-connection channel cap reached; refusing new channel"
263            );
264            reply.reject(ChannelOpenFailure::ResourceShortage).await;
265            return Ok(());
266        }
267
268        let (tx, mut rx) = mpsc::unbounded_channel::<Request>();
269        let mut joinset = JoinSet::new();
270
271        let (channel_id, session_handle) = (channel.id(), session.handle());
272        let dev = self.dev.clone();
273
274        joinset.spawn(async move {
275            let rt = tokio::runtime::Handle::current();
276
277            let mut handler = match H::new(rt, channel_id, session_handle.clone(), dev, &accept) {
278                Ok(handler) => handler,
279                Err(e) => {
280                    let e = e.into();
281                    tracing::error!(error = %e, %channel_id, "spawning channel handler");
282
283                    if session_handle.close(channel_id).await.is_err() {
284                        tracing::error!("failed closing channel after handler init error");
285                    };
286
287                    return;
288                }
289            };
290
291            while let Some((_channel, evt)) = rx.recv().await {
292                let result = handler.handle_event(&evt).await;
293
294                if let Err(e) = result {
295                    let e = e.into();
296                    tracing::error!(error = %e, %channel_id, ?evt, "handling event");
297
298                    if session_handle.close(channel_id).await.is_err() {
299                        tracing::error!("failed closing channel after event handler error");
300                    };
301
302                    break;
303                }
304            }
305
306            tracing::debug!(?channel_id, "closed");
307        });
308
309        self.channel_state.insert(
310            channel.id(),
311            ChannelState {
312                channel: channel.id(),
313                tx,
314                _joinset: joinset,
315            },
316        );
317
318        // `accept()` is what confirms the channel. No `channel_success` here: until the accept is
319        // processed the channel is still inside the pending open (held by `reply`), never in the
320        // session's channel map, so a pre-accept `channel_success` is a silent no-op — and would
321        // hit its `assert!(channel.confirmed)` if russh ever registered the channel earlier.
322        reply.accept().await;
323
324        Ok(())
325    }
326
327    async fn channel_close(
328        &mut self,
329        channel: ChannelId,
330        session: &mut Session,
331    ) -> Result<(), Self::Error> {
332        tracing::trace!(?channel, "session closed");
333
334        self.get_channel(channel)?.send(ChannelEvent::Close);
335        self.channel_state.remove(&channel);
336
337        session.channel_success(channel)?;
338
339        Ok(())
340    }
341
342    async fn signal(
343        &mut self,
344        channel: ChannelId,
345        signal: Sig,
346        session: &mut Session,
347    ) -> Result<(), Self::Error> {
348        self.get_channel(channel)?
349            .send(ChannelEvent::Signal(signal));
350        session.channel_success(channel)?;
351
352        Ok(())
353    }
354
355    async fn data(
356        &mut self,
357        channel: ChannelId,
358        data: &[u8],
359        session: &mut Session,
360    ) -> Result<(), Self::Error> {
361        self.get_channel(channel)?
362            .send(ChannelEvent::Data(data.into()));
363
364        session.channel_success(channel)?;
365
366        Ok(())
367    }
368
369    async fn channel_eof(
370        &mut self,
371        channel: ChannelId,
372        session: &mut Session,
373    ) -> Result<(), Self::Error> {
374        self.get_channel(channel)?.send(ChannelEvent::Eof);
375        session.channel_success(channel)?;
376
377        Ok(())
378    }
379
380    async fn window_change_request(
381        &mut self,
382        channel: ChannelId,
383        col_width: u32,
384        row_height: u32,
385        _: u32,
386        _: u32,
387        session: &mut Session,
388    ) -> Result<(), Self::Error> {
389        self.get_channel(channel)?.send(ChannelEvent::Resize {
390            width: col_width as _,
391            height: row_height as _,
392        });
393
394        session.channel_success(channel)?;
395
396        Ok(())
397    }
398
399    async fn pty_request(
400        &mut self,
401        channel: ChannelId,
402        _: &str,
403        col_width: u32,
404        row_height: u32,
405        _: u32,
406        _: u32,
407        _: &[(Pty, u32)],
408        session: &mut Session,
409    ) -> Result<(), Self::Error> {
410        self.get_channel(channel)?.send(ChannelEvent::Resize {
411            width: col_width as _,
412            height: row_height as _,
413        });
414
415        session.channel_success(channel)?;
416
417        Ok(())
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::{
424        DEFAULT_RECORDING_REFUSAL, MAX_CHANNELS_PER_CONN, at_channel_cap, recording_refusal,
425    };
426    use crate::ssh::SshAccept;
427
428    /// The per-connection channel cap (fork-bomb guard) refuses at and beyond `MAX_CHANNELS_PER_CONN`
429    /// and allows below it. Pins the exact boundary: a `>=`→`>` flip would let `MAX_CHANNELS_PER_CONN`
430    /// open channels become `MAX_CHANNELS_PER_CONN + 1`, failing the `== cap` assertion below.
431    #[test]
432    fn channel_cap_boundary_is_inclusive() {
433        // Below the cap: still allowed.
434        assert!(!at_channel_cap(MAX_CHANNELS_PER_CONN - 1));
435        assert!(!at_channel_cap(15));
436        // At the cap: refuse the next open (the channel that would make it 17).
437        assert!(at_channel_cap(MAX_CHANNELS_PER_CONN));
438        assert!(at_channel_cap(16));
439        // Above the cap (defensive): still refused.
440        assert!(at_channel_cap(17));
441        // The const itself is the documented value.
442        assert_eq!(MAX_CHANNELS_PER_CONN, 16);
443    }
444
445    fn accept(recording_required: bool, refusal_message: &str) -> SshAccept {
446        SshAccept {
447            local_user: "root".to_string(),
448            accept_env: Vec::new(),
449            session_duration_nanos: None,
450            allow_agent_forwarding: false,
451            allow_local_port_forwarding: false,
452            allow_remote_port_forwarding: false,
453            recorders: Vec::new(),
454            recording_required,
455            recording_refusal_message: refusal_message.to_string(),
456        }
457    }
458
459    /// tsr-0h2: an accept that demands recording must be REFUSED (the bypass is closed). With a
460    /// policy-supplied message, that exact message is used; without one, the default is logged.
461    #[test]
462    fn recording_required_accept_is_refused() {
463        // Policy-supplied refusal message wins.
464        assert_eq!(
465            recording_refusal(&accept(true, "recording required by policy")),
466            Some("recording required by policy".to_string()),
467        );
468        // No message → default refusal text, but still a refusal (Some).
469        assert_eq!(
470            recording_refusal(&accept(true, "")),
471            Some(DEFAULT_RECORDING_REFUSAL.to_string()),
472        );
473    }
474
475    /// Regression guard for the common path: a normal accept (no recording demanded) is NOT refused,
476    /// so the gate is a no-op and the session proceeds.
477    #[test]
478    fn normal_accept_is_not_refused() {
479        assert_eq!(recording_refusal(&accept(false, "")), None);
480        // Even a stray non-empty message never forces a refusal when recording isn't required.
481        assert_eq!(recording_refusal(&accept(false, "ignored")), None);
482    }
483}