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}