Skip to main content

tailscale/ssh/
mod.rs

1//! Support for tailnet-native, in-process SSH servers.
2//!
3//! # Overview
4//!
5//! This module (`tailscale::ssh`) holds helpers for running SSH servers on the tailnet
6//! using [`russh`]. They delegate their functionality to the [`Handler`] trait, which is
7//! `russh`'s notion of a _connection_ handler, i.e. a single incoming TCP connection gets
8//! a single instance of [`Handler`].
9//!
10//! ## Channels
11//!
12//! SSH has a nested notion of channels, which are multiplexed over a single connection.
13//! The terminal session you open over a normal machine-to-machine ssh connection runs in a
14//! channel, and in principle, you can have multiple channels open on the same connection.
15//!
16//! The `channel_server` module provides a [`ChannelServer`] type that separates out the
17//! per-channel handler logic from `russh`'s monolithic [`Handler`]. Channel handler logic
18//! is supported here by [`ChannelHandler`], which is passed into [`ChannelServer`] and
19//! processes a [`ChannelEvent`] stream for each channel that's opened.
20//!
21//! ## Terminal applications
22//!
23//! Support for building per-channel terminal application is provided by [`RatatuiTerm`],
24//! which implements [`ChannelHandler`] to drive a
25//! [`ratatui::Terminal`][::ratatui::Terminal]. The user provides an implementation of
26//! [`RatatuiApp`] that consumes input data and supports draws to the screen, and the
27//! [`RatatuiTerm`] drives it automatically.
28
29pub extern crate russh;
30
31use std::{fmt::Debug, net::SocketAddr, sync::Arc};
32
33/// Upper bound on concurrent SSH connections served by [`Device::serve_ssh`]. The accept loop
34/// back-pressures past this cap (defense-in-depth beside the per-connection channel cap).
35const MAX_SSH_CONNECTIONS: usize = 64;
36
37use russh::server::Handler;
38use ts_control::SshConnIdentity;
39pub use ts_control::{SshAccept, SshDecision, SshDenyReason, SshPolicy};
40
41mod channel_server;
42mod channel_write;
43mod ratatui;
44mod shell;
45
46pub use channel_server::{ChannelEvent, ChannelHandler, ChannelServer};
47pub use ratatui::{RatatuiApp, RatatuiEnv, RatatuiTerm};
48pub use shell::ShellHandler;
49
50impl crate::Device {
51    /// Authorize an incoming Tailscale SSH connection from `remote` requesting local user
52    /// `requested_user`, against the control-pushed SSH policy.
53    ///
54    /// **Fail-closed.** This is the Rust analogue of Go `tailssh`'s policy evaluation. It:
55    /// 1. resolves `remote`'s IP to a known tailnet peer — an unknown source is denied;
56    /// 2. fetches the current [`SshPolicy`][ts_control::SshPolicy] — **no policy means deny-all**;
57    /// 3. evaluates the policy (first-match-wins, default-deny) against the peer's identity.
58    ///
59    /// Returns the [`SshDecision`]. Callers MUST reject the connection on any
60    /// [`SshDecision::Deny`]. Any lookup error is surfaced as `Err` and must also be treated as a
61    /// rejection by the caller — the connection is never allowed on the error path.
62    ///
63    /// NOTE: `userLogin`-principal matching requires the connecting peer's owner login, which this
64    /// fork's domain node model does not yet retain (it is reported as `None`); such principals
65    /// therefore never match here. Node-id / node-IP / `any` principals match normally.
66    pub async fn authorize_ssh(
67        &self,
68        remote: SocketAddr,
69        requested_user: &str,
70    ) -> Result<SshDecision, crate::Error> {
71        use ts_control::SshDenyReason;
72
73        let Some(peer) = self.peer_by_tailnet_ip(remote.ip()).await? else {
74            tracing::warn!(remote = %remote, "ssh: source IP does not match a known tailnet peer");
75            return Ok(SshDecision::Deny(SshDenyReason::NoRuleMatched));
76        };
77
78        let Some(policy) = self.ssh_policy().await? else {
79            tracing::warn!(remote = %remote, "ssh: no SSH policy pushed by control; deny-all");
80            return Ok(SshDecision::Deny(SshDenyReason::NoRuleMatched));
81        };
82
83        let id = SshConnIdentity {
84            stable_id: peer.stable_id.0.clone(),
85            src_ip: remote.ip(),
86            // The domain node model does not retain the owner login; see method docs.
87            user_login: None,
88        };
89
90        Ok(policy.evaluate_at_unix(&id, requested_user, now_unix_secs()))
91    }
92}
93
94/// Current wall-clock time as Unix seconds, derived from [`std::time::SystemTime`].
95///
96/// The root crate does not depend on `chrono`, and the workspace pins it without the `clock`
97/// feature anyway, so policy evaluation takes a Unix timestamp instead of a `DateTime`. An
98/// unreadable clock (time before the Unix epoch) is clamped to [`i64::MAX`] so SSH-rule expiry
99/// **fails closed**: a broken clock makes every time-limited rule look already-expired (deny)
100/// rather than perpetually-live.
101fn now_unix_secs() -> i64 {
102    std::time::SystemTime::now()
103        .duration_since(std::time::UNIX_EPOCH)
104        .map(|d| d.as_secs() as i64)
105        .unwrap_or(i64::MAX)
106}
107
108/// Trait to construct a new [`Handler`] from a Tailscale [`Device`][crate::Device] and
109/// the address of a connecting client.
110///
111/// Rephrasing of [`russh::server::Server`] that includes the Tailscale device as an
112/// argument and skips the support for off-tailnet IP and Unix sockets.
113pub trait TailnetServer {
114    /// Construct a new handler.
115    fn new_client(dev: Arc<crate::Device>, addr: SocketAddr) -> Self;
116}
117
118impl crate::Device {
119    /// Serve an ssh service on the given TCP address.
120    ///
121    /// This is a minimal helper that just wires up the relevant pieces. All the
122    /// authentication and actual SSH server logic must be implemented by the caller in
123    /// the `TailnetServer` (`H`) and configured by `config`.
124    pub async fn serve_ssh<H>(
125        self: Arc<Self>,
126        config: russh::server::Config,
127        listen_addr: SocketAddr,
128    ) -> Result<(), crate::Error>
129    where
130        H: TailnetServer + Handler + Send + 'static,
131        H::Error: Debug,
132    {
133        let config = Arc::new(config);
134        let listener = self.tcp_listen(listen_addr).await?;
135
136        tracing::info!(%listen_addr, "ssh server listening");
137
138        // Bound concurrent connections (back-pressure: acquire a permit *before* accepting so the
139        // loop stops pulling connections off the listener once at the cap). Per-connection sessions
140        // are held in a `JoinSet` owned by this future rather than detached via bare `tokio::spawn`,
141        // so dropping the `serve_ssh` future (the caller's cancellation model) both stops accepting
142        // and aborts in-flight sessions instead of leaking them.
143        let sem = Arc::new(tokio::sync::Semaphore::new(MAX_SSH_CONNECTIONS));
144        let mut sessions = tokio::task::JoinSet::new();
145
146        loop {
147            // Reap finished sessions opportunistically so the `JoinSet` does not grow unbounded.
148            while sessions.try_join_next().is_some() {}
149
150            // The semaphore is never closed in this loop; if it somehow is, stop accepting.
151            let Ok(permit) = sem.clone().acquire_owned().await else {
152                return Ok(());
153            };
154            let conn = listener.accept().await?;
155
156            let handler = H::new_client(self.clone(), conn.remote_addr());
157            let config = config.clone();
158
159            sessions.spawn(async move {
160                // Hold the permit for the connection's lifetime; dropping it on task end frees the
161                // slot for the next accept.
162                let _permit = permit;
163                let sess = match russh::server::run_stream(config, conn, handler).await {
164                    Ok(sess) => sess,
165                    Err(e) => {
166                        tracing::error!(error = ?e, "establishing session");
167                        return;
168                    }
169                };
170
171                match sess.await {
172                    Ok(()) => {}
173                    Err(e) => {
174                        tracing::error!(error = ?e, "running ssh session");
175                    }
176                }
177            });
178        }
179    }
180
181    /// Run a turnkey Tailscale SSH server on `listen_addr` (tailnet overlay) that grants authorized
182    /// connections an interactive login shell as their policy-mapped local user.
183    ///
184    /// Authorization is the control-pushed SSH policy (see [`Device::authorize_ssh`]) — fail-closed:
185    /// unknown source, no policy, no matching rule, or any error rejects. The accepted connection's
186    /// `local_user` is resolved against the local passwd database and the login shell is spawned in
187    /// a PTY **after dropping privileges** to that user's uid/gid (the daemon must run as root to do
188    /// so; if it cannot, the session fails closed). Mirrors Go `tailssh`'s incubator shell path.
189    ///
190    /// Only the interactive login-shell path is implemented: `pty-req` → `<shell> -l`,
191    /// `window-change` → `TIOCSWINSZ`, and an `exit-status` on shell exit. The exec form
192    /// (`<shell> -c <cmd>`) is **not** supported because [`ChannelEvent`] does not surface an SSH
193    /// `exec` request in this fork's channel abstraction.
194    pub async fn listen_ssh(
195        self: Arc<Self>,
196        config: russh::server::Config,
197        listen_addr: SocketAddr,
198    ) -> Result<(), crate::Error> {
199        self.serve_ssh::<ChannelServer<ShellHandler>>(config, listen_addr)
200            .await
201    }
202
203    /// Serve an SSH TUI service on the given TCP address.
204    ///
205    /// Wrapper around [`serve_ssh`][crate::Device::serve_ssh] to specifically use
206    /// [`ChannelServer`] around a [`RatatuiTerm`] using `App`.
207    pub async fn serve_ssh_tui<App>(
208        self: Arc<Self>,
209        config: russh::server::Config,
210        listen_addr: SocketAddr,
211    ) -> Result<(), crate::Error>
212    where
213        App: RatatuiApp + Default + Send + 'static,
214    {
215        self.serve_ssh::<ChannelServer<RatatuiTerm<App>>>(config, listen_addr)
216            .await
217    }
218}