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/// Everything a per-channel handler is told about the connection that opened it.
20///
21/// Built once per connection by the fail-closed authorization in
22/// [`auth_none`][russh::server::Handler::auth_none] and handed to every channel opened on it.
23#[derive(Debug, Clone)]
24pub struct ChannelContext {
25    /// The authorization decision. Its [`local_user`][SshAccept::local_user] is the policy-mapped
26    /// identity the session must run as, and its `recorders` / `on_recording_failure` are the
27    /// session-recording obligation the handler has to honor.
28    pub accept: SshAccept,
29    /// The username the client presented (Go's `sshUser`, before the policy's user mapping).
30    pub ssh_user: String,
31    /// The tailnet address the connection came from.
32    pub remote: SocketAddr,
33    /// The connecting tailnet peer, when the source address resolved to one.
34    pub src_node: Option<crate::NodeInfo>,
35    /// Identifier shared by every session multiplexed on this connection, recorded in a session
36    /// recording's cast header so the recordings of one connection can be grouped.
37    pub conn_id: String,
38}
39
40/// Handler for a channel session.
41pub trait ChannelHandler: Sized {
42    /// Error this handler produces.
43    type Error: Into<std::io::Error> + std::error::Error;
44
45    /// Whether this handler streams its session to the policy's `recorders`.
46    ///
47    /// **This is a fail-closed gate, not a hint.** A policy rule with a non-empty `recorders` list
48    /// obliges the server to record the session; a handler that leaves this `false` is refused
49    /// such a connection outright rather than silently running it un-recorded. Only set it to
50    /// `true` in a handler that actually calls
51    /// [`SessionRecording`][crate::ssh::recording::SessionRecording].
52    const RECORDS_SESSION: bool = false;
53
54    /// Construct a new per-channel handler.
55    ///
56    /// `ctx` carries the single fail-closed authorization decision made in
57    /// [`auth_none`][russh::server::Handler::auth_none]. Handlers MUST NOT re-evaluate policy or
58    /// substitute a different user — the accepted identity is the sole authorization source.
59    ///
60    /// This is `async` because a handler may have to reach the network before the session may
61    /// start: a recorded session dials its recorder here, and a session that must be recorded but
62    /// cannot be is refused by returning `Err` — so the shell is never spawned first and recorded
63    /// second.
64    fn new(
65        handle: tokio::runtime::Handle,
66        channel_id: ChannelId,
67        session: Handle,
68        dev: Arc<Device>,
69        ctx: &ChannelContext,
70    ) -> impl Future<Output = Result<Self, Self::Error>> + Send;
71
72    /// Handle an event from the channel.
73    fn handle_event(
74        &mut self,
75        event: &ChannelEvent,
76    ) -> impl Future<Output = Result<(), Self::Error>> + Send;
77}
78
79/// Implementation of [`russh::server::Handler`] which provides per-channel session
80/// handlers using a parametric [`ChannelHandler`].
81///
82/// Primary motivation is to support custom console or TUI sessions over tailnet SSH
83/// connections.
84///
85/// # Authentication and authorization
86///
87/// Incoming connections are gated by the control-pushed Tailscale SSH policy: [`auth_none`]
88/// resolves the source IP to a known tailnet peer and evaluates the policy via
89/// [`Device::authorize_ssh`][crate::Device::authorize_ssh] (fail-closed — an unknown peer, an
90/// absent policy, or a non-matching policy all reject). The `ssh` policy block's accept/reject
91/// rules, principal matching, and SSH-user mapping are honored.
92///
93/// A rule that **demands** session recording (non-empty `recorders`) is honored by handlers that
94/// declare [`ChannelHandler::RECORDS_SESSION`] — [`ShellHandler`][crate::ssh::ShellHandler] streams
95/// the session to the recorders and applies `onRecordingFailure`. For any other handler, and for a
96/// rule carrying `holdAndDelegate` (no delegate round-trip exists), the connection is refused
97/// **fail-closed** rather than silently run without the capability the policy demanded (see
98/// [`auth_none`]).
99///
100/// [`auth_none`]: russh::server::Handler::auth_none
101pub struct ChannelServer<H> {
102    channel_state: HashMap<ChannelId, ChannelState>,
103    remote: SocketAddr,
104    dev: Arc<Device>,
105    /// The authorization decision and connection facts from the single
106    /// [`auth_none`][russh::server::Handler::auth_none] decision, stashed so per-channel handlers
107    /// run as the policy-mapped user. `None` until a successful `auth_none`; a channel open with
108    /// `None` here fails closed.
109    accepted: Option<ChannelContext>,
110    /// Identifier for this connection, shared by every session multiplexed on it.
111    conn_id: String,
112    _handler: PhantomSend<H>,
113}
114
115struct PhantomSend<H>(PhantomData<fn() -> H>);
116
117/// Maximum number of concurrent channels a single SSH connection may open. Each channel spawns a
118/// session handler (e.g. a login shell), so this caps the per-connection resource/process fan-out
119/// an authorized-but-hostile peer can induce. SSH clients realistically open one (or a few)
120/// sessions per connection, so this is generous for legitimate use.
121const MAX_CHANNELS_PER_CONN: usize = 16;
122
123/// Whether a connection at `open_channels` currently-open channels has reached the per-connection
124/// channel cap and must refuse the next channel open. Pure boundary predicate extracted from
125/// [`ChannelServer::channel_open_session`] so the fork-bomb guard's edge can be unit-tested without
126/// a live russh [`Session`].
127fn at_channel_cap(open_channels: usize) -> bool {
128    open_channels >= MAX_CHANNELS_PER_CONN
129}
130
131/// Fallback message logged when a session is refused for an action the server cannot honor and the
132/// policy supplied no message of its own.
133const DEFAULT_UNSUPPORTED_REFUSAL: &str =
134    "policy requires a capability this SSH server cannot provide";
135
136/// The fail-closed gate for policy actions this server cannot honor, extracted as a pure predicate
137/// so it can be unit-tested without a live russh [`Session`]/[`Device`] (mirrors
138/// [`at_channel_cap`]).
139///
140/// Returns `Some(message)` when the accepted session must be **refused**, which is either:
141///
142/// * the rule carries a `holdAndDelegate` URL — there is no delegate round-trip, so the decision
143///   the policy wanted deferred to control can never be made; or
144/// * the rule demands session recording and `handler_records` is `false`, i.e. the configured
145///   [`ChannelHandler`] does not stream its session anywhere. Running it would be exactly the
146///   silent un-recorded session the policy forbade.
147///
148/// A rule that demands recording with a recording-capable handler returns `None`: the session is
149/// admitted here and the recorder is dialed by the handler, which then applies Go's
150/// `onRecordingFailure` semantics (fail-open unless `rejectSessionWithMessage` is set).
151///
152/// The message is the policy's
153/// [`recording_refusal_message`][crate::ssh::SshAccept::recording_refusal_message] when non-empty,
154/// else [`DEFAULT_UNSUPPORTED_REFUSAL`].
155fn unsupported_action_refusal(accept: &SshAccept, handler_records: bool) -> Option<String> {
156    let unsupported =
157        !accept.hold_and_delegate.is_empty() || (!accept.recorders.is_empty() && !handler_records);
158    if !unsupported {
159        return None;
160    }
161    if accept.recording_refusal_message.is_empty() {
162        Some(DEFAULT_UNSUPPORTED_REFUSAL.to_string())
163    } else {
164        Some(accept.recording_refusal_message.clone())
165    }
166}
167
168#[derive(thiserror::Error, Debug, Copy, Clone, PartialEq, Eq)]
169#[error("no such channel")]
170struct NoChannel;
171
172/// State of a channel in [`ChannelServer`].
173struct ChannelState {
174    channel: ChannelId,
175    tx: UnboundedSender<Request>,
176    _joinset: JoinSet<()>,
177}
178
179impl ChannelState {
180    fn send(&self, event: ChannelEvent) {
181        if self.tx.send((self.channel, event)).is_err() {
182            tracing::error!(channel = %self.channel, "failed to send event");
183        }
184    }
185}
186
187impl<H> ChannelServer<H> {
188    fn get_channel(
189        &mut self,
190        id: ChannelId,
191    ) -> Result<&mut ChannelState, Box<dyn std::error::Error + Send + Sync + 'static>> {
192        self.channel_state.get_mut(&id).ok_or(Box::new(NoChannel))
193    }
194}
195
196impl<H> TailnetServer for ChannelServer<H> {
197    fn new_client(dev: Arc<Device>, addr: SocketAddr) -> Self {
198        Self {
199            channel_state: Default::default(),
200            dev,
201            remote: addr,
202            accepted: None,
203            conn_id: crate::ssh::new_conn_id(crate::ssh::now_unix_secs()),
204            _handler: PhantomSend(PhantomData),
205        }
206    }
207}
208
209/// An event that may be generated by a channel connected to a [`ChannelServer`].
210#[derive(Debug, Clone)]
211pub enum ChannelEvent {
212    /// Data was received over the channel.
213    Data(Vec<u8>),
214    /// A resize event occurred.
215    Resize {
216        /// The new width of the tty.
217        width: u16,
218        /// The new height of the tty.
219        height: u16,
220    },
221    /// A signal was sent over the channel.
222    Signal(Sig),
223    /// The channel was closed.
224    Close,
225    /// The channel received EOF.
226    Eof,
227}
228
229impl<H> russh::server::Handler for ChannelServer<H>
230where
231    H: ChannelHandler + Send,
232    H::Error: Send,
233{
234    type Error = Box<dyn std::error::Error + Send + Sync + 'static>;
235
236    #[tracing::instrument(skip_all, fields(user = %user, remote = ?self.remote))]
237    async fn auth_none(&mut self, user: &str) -> Result<Auth, Self::Error> {
238        // Enforce the control-pushed Tailscale SSH policy. Fail-closed: an unknown source, an
239        // absent policy, a non-matching policy, or any lookup error all reject the connection.
240        match self.dev.authorize_ssh(self.remote, user).await {
241            Ok(crate::ssh::SshDecision::Accept(accept)) => {
242                // SECURITY: a matched rule may demand a capability the configured handler cannot
243                // provide — a `holdAndDelegate` decision (no delegate round-trip exists), or
244                // session recording with a handler that does not record. Refuse the session
245                // (fail-closed) rather than silently downgrade it to a plain accept.
246                // `Auth::reject()` (the SSH `none`-method rejection) carries no client-visible
247                // message, so the policy's refusal message is surfaced in the warning log.
248                if let Some(msg) = unsupported_action_refusal(&accept, H::RECORDS_SESSION) {
249                    tracing::warn!(
250                        local_user = %accept.local_user,
251                        recorders = ?accept.recorders,
252                        message = %msg,
253                        "ssh: session refused: policy requires a capability this server cannot provide"
254                    );
255                    return Ok(Auth::reject());
256                }
257                tracing::debug!(
258                    local_user = %accept.local_user,
259                    recorders = ?accept.recorders,
260                    "ssh: policy accepted connection"
261                );
262                // The connecting peer, for the session recording's cast header. `authorize_ssh`
263                // already proved the source resolves to a known peer, so this is a re-read of the
264                // same peer table, never a second authorization decision.
265                let src_node = self
266                    .dev
267                    .peer_by_tailnet_ip(self.remote.ip())
268                    .await
269                    .unwrap_or_else(|e| {
270                        tracing::debug!(error = %e, "ssh: re-reading the connecting peer");
271                        None
272                    });
273                // Stash the accepted identity so the per-channel handler runs as the
274                // policy-mapped local user. This is the single fail-closed authorization point;
275                // the handler never re-evaluates policy.
276                self.accepted = Some(ChannelContext {
277                    accept,
278                    ssh_user: user.to_string(),
279                    remote: self.remote,
280                    src_node,
281                    conn_id: self.conn_id.clone(),
282                });
283                Ok(Auth::Accept)
284            }
285            Ok(crate::ssh::SshDecision::Deny(reason)) => {
286                tracing::warn!(?reason, "ssh: policy denied connection");
287                Ok(Auth::reject())
288            }
289            Err(e) => {
290                tracing::error!(error = %e, "ssh: authorization failed; rejecting");
291                Ok(Auth::reject())
292            }
293        }
294    }
295
296    async fn channel_open_session(
297        &mut self,
298        channel: Channel<Msg>,
299        reply: ChannelOpenHandle,
300        session: &mut Session,
301    ) -> Result<(), Self::Error> {
302        tracing::debug!(channel = ?channel.id(), "new session");
303
304        // Fail closed: a channel open must be preceded by a successful `auth_none` that stashed
305        // the accepted identity. If it is somehow absent, refuse to open the channel rather than
306        // run a handler with no authorized user.
307        let Some(ctx) = self.accepted.clone() else {
308            tracing::error!(
309                channel = ?channel.id(),
310                "ssh: channel open with no accepted identity; refusing"
311            );
312            reply
313                .reject(ChannelOpenFailure::AdministrativelyProhibited)
314                .await;
315            return Ok(());
316        };
317
318        // Bound the number of concurrent channels (each opens a session/handler — e.g. a login
319        // shell). Without this an authorized-but-hostile peer could open unbounded channels on one
320        // connection and fork-bomb the host with session handlers. Past the cap, refuse new channels.
321        if at_channel_cap(self.channel_state.len()) {
322            tracing::warn!(
323                channel = ?channel.id(),
324                cap = MAX_CHANNELS_PER_CONN,
325                "ssh: per-connection channel cap reached; refusing new channel"
326            );
327            reply.reject(ChannelOpenFailure::ResourceShortage).await;
328            return Ok(());
329        }
330
331        let (tx, mut rx) = mpsc::unbounded_channel::<Request>();
332        let mut joinset = JoinSet::new();
333
334        let (channel_id, session_handle) = (channel.id(), session.handle());
335        let dev = self.dev.clone();
336
337        joinset.spawn(async move {
338            let rt = tokio::runtime::Handle::current();
339
340            let mut handler = match H::new(rt, channel_id, session_handle.clone(), dev, &ctx).await
341            {
342                Ok(handler) => handler,
343                Err(e) => {
344                    let e = e.into();
345                    tracing::error!(error = %e, %channel_id, "spawning channel handler");
346
347                    if session_handle.close(channel_id).await.is_err() {
348                        tracing::error!("failed closing channel after handler init error");
349                    };
350
351                    return;
352                }
353            };
354
355            while let Some((_channel, evt)) = rx.recv().await {
356                let result = handler.handle_event(&evt).await;
357
358                if let Err(e) = result {
359                    let e = e.into();
360                    tracing::error!(error = %e, %channel_id, ?evt, "handling event");
361
362                    if session_handle.close(channel_id).await.is_err() {
363                        tracing::error!("failed closing channel after event handler error");
364                    };
365
366                    break;
367                }
368            }
369
370            tracing::debug!(?channel_id, "closed");
371        });
372
373        self.channel_state.insert(
374            channel.id(),
375            ChannelState {
376                channel: channel.id(),
377                tx,
378                _joinset: joinset,
379            },
380        );
381
382        // `accept()` is what confirms the channel. No `channel_success` here: until the accept is
383        // processed the channel is still inside the pending open (held by `reply`), never in the
384        // session's channel map, so a pre-accept `channel_success` is a silent no-op — and would
385        // hit its `assert!(channel.confirmed)` if russh ever registered the channel earlier.
386        reply.accept().await;
387
388        Ok(())
389    }
390
391    async fn channel_close(
392        &mut self,
393        channel: ChannelId,
394        session: &mut Session,
395    ) -> Result<(), Self::Error> {
396        tracing::trace!(?channel, "session closed");
397
398        self.get_channel(channel)?.send(ChannelEvent::Close);
399        self.channel_state.remove(&channel);
400
401        session.channel_success(channel)?;
402
403        Ok(())
404    }
405
406    async fn signal(
407        &mut self,
408        channel: ChannelId,
409        signal: Sig,
410        session: &mut Session,
411    ) -> Result<(), Self::Error> {
412        self.get_channel(channel)?
413            .send(ChannelEvent::Signal(signal));
414        session.channel_success(channel)?;
415
416        Ok(())
417    }
418
419    async fn data(
420        &mut self,
421        channel: ChannelId,
422        data: &[u8],
423        session: &mut Session,
424    ) -> Result<(), Self::Error> {
425        self.get_channel(channel)?
426            .send(ChannelEvent::Data(data.into()));
427
428        session.channel_success(channel)?;
429
430        Ok(())
431    }
432
433    async fn channel_eof(
434        &mut self,
435        channel: ChannelId,
436        session: &mut Session,
437    ) -> Result<(), Self::Error> {
438        self.get_channel(channel)?.send(ChannelEvent::Eof);
439        session.channel_success(channel)?;
440
441        Ok(())
442    }
443
444    async fn window_change_request(
445        &mut self,
446        channel: ChannelId,
447        col_width: u32,
448        row_height: u32,
449        _: u32,
450        _: u32,
451        session: &mut Session,
452    ) -> Result<(), Self::Error> {
453        self.get_channel(channel)?.send(ChannelEvent::Resize {
454            width: col_width as _,
455            height: row_height as _,
456        });
457
458        session.channel_success(channel)?;
459
460        Ok(())
461    }
462
463    async fn pty_request(
464        &mut self,
465        channel: ChannelId,
466        _: &str,
467        col_width: u32,
468        row_height: u32,
469        _: u32,
470        _: u32,
471        _: &[(Pty, u32)],
472        session: &mut Session,
473    ) -> Result<(), Self::Error> {
474        self.get_channel(channel)?.send(ChannelEvent::Resize {
475            width: col_width as _,
476            height: row_height as _,
477        });
478
479        session.channel_success(channel)?;
480
481        Ok(())
482    }
483}
484
485#[cfg(test)]
486mod tests {
487    use super::{
488        DEFAULT_UNSUPPORTED_REFUSAL, MAX_CHANNELS_PER_CONN, at_channel_cap,
489        unsupported_action_refusal,
490    };
491    use crate::ssh::SshAccept;
492
493    /// The per-connection channel cap (fork-bomb guard) refuses at and beyond `MAX_CHANNELS_PER_CONN`
494    /// and allows below it. Pins the exact boundary: a `>=`→`>` flip would let `MAX_CHANNELS_PER_CONN`
495    /// open channels become `MAX_CHANNELS_PER_CONN + 1`, failing the `== cap` assertion below.
496    #[test]
497    fn channel_cap_boundary_is_inclusive() {
498        // Below the cap: still allowed.
499        assert!(!at_channel_cap(MAX_CHANNELS_PER_CONN - 1));
500        assert!(!at_channel_cap(15));
501        // At the cap: refuse the next open (the channel that would make it 17).
502        assert!(at_channel_cap(MAX_CHANNELS_PER_CONN));
503        assert!(at_channel_cap(16));
504        // Above the cap (defensive): still refused.
505        assert!(at_channel_cap(17));
506        // The const itself is the documented value.
507        assert_eq!(MAX_CHANNELS_PER_CONN, 16);
508    }
509
510    /// An accept carrying `recorders`, a `holdAndDelegate` URL, and a refusal message — the three
511    /// inputs the gate reads.
512    fn accept(recorders: &[&str], hold_and_delegate: &str, refusal_message: &str) -> SshAccept {
513        SshAccept {
514            local_user: "root".to_string(),
515            accept_env: Vec::new(),
516            session_duration_nanos: None,
517            allow_agent_forwarding: false,
518            allow_local_port_forwarding: false,
519            allow_remote_port_forwarding: false,
520            recorders: recorders.iter().map(|r| r.parse().unwrap()).collect(),
521            on_recording_failure: None,
522            hold_and_delegate: hold_and_delegate.to_string(),
523            recording_refusal_message: refusal_message.to_string(),
524        }
525    }
526
527    /// A rule demanding recording is admitted for a handler that records (the transport then
528    /// applies `onRecordingFailure`), and REFUSED for one that does not — otherwise the session
529    /// would run un-recorded, which is the bypass the policy forbids.
530    #[test]
531    fn recording_demand_is_gated_on_handler_support() {
532        let a = accept(&["192.0.2.10:8080"], "", "recording required by policy");
533        assert_eq!(
534            unsupported_action_refusal(&a, true),
535            None,
536            "a recording-capable handler must be allowed to start and record the session"
537        );
538        assert_eq!(
539            unsupported_action_refusal(&a, false),
540            Some("recording required by policy".to_string()),
541            "a handler that cannot record must not run a session the policy says to record"
542        );
543    }
544
545    /// `holdAndDelegate` has no transport at all, so it is refused whatever the handler can do.
546    #[test]
547    fn hold_and_delegate_is_refused_for_every_handler() {
548        let a = accept(&[], "https://control.example/ssh/action/xyz", "");
549        for handler_records in [true, false] {
550            assert_eq!(
551                unsupported_action_refusal(&a, handler_records),
552                Some(DEFAULT_UNSUPPORTED_REFUSAL.to_string()),
553                "holdAndDelegate must be refused (handler_records={handler_records})"
554            );
555        }
556    }
557
558    /// Regression guard for the common path: a plain accept is NOT refused, so the gate is a no-op
559    /// and the session proceeds.
560    #[test]
561    fn normal_accept_is_not_refused() {
562        assert_eq!(
563            unsupported_action_refusal(&accept(&[], "", ""), false),
564            None
565        );
566        // Even a stray non-empty message never forces a refusal when nothing is demanded.
567        assert_eq!(
568            unsupported_action_refusal(&accept(&[], "", "ignored"), false),
569            None
570        );
571    }
572}