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, SshRecorderFailureAction};
40
41mod channel_server;
42mod channel_write;
43mod ratatui;
44pub mod recording;
45mod shell;
46
47pub use channel_server::{ChannelContext, ChannelEvent, ChannelHandler, ChannelServer};
48pub use ratatui::{RatatuiApp, RatatuiEnv, RatatuiTerm};
49pub use shell::ShellHandler;
50
51impl crate::Device {
52    /// Authorize an incoming Tailscale SSH connection from `remote` requesting local user
53    /// `requested_user`, against the control-pushed SSH policy.
54    ///
55    /// **Fail-closed.** This is the Rust analogue of Go `tailssh`'s policy evaluation. It:
56    /// 1. resolves `remote`'s IP to a known tailnet peer — an unknown source is denied;
57    /// 2. fetches the current [`SshPolicy`][ts_control::SshPolicy] — **no policy means deny-all**;
58    /// 3. evaluates the policy (first-match-wins, default-deny) against the peer's identity.
59    ///
60    /// Returns the [`SshDecision`]. Callers MUST reject the connection on any
61    /// [`SshDecision::Deny`]. Any lookup error is surfaced as `Err` and must also be treated as a
62    /// rejection by the caller — the connection is never allowed on the error path.
63    ///
64    /// NOTE: `userLogin`-principal matching requires the connecting peer's owner login, which this
65    /// fork's domain node model does not yet retain (it is reported as `None`); such principals
66    /// therefore never match here. Node-id / node-IP / `any` principals match normally.
67    pub async fn authorize_ssh(
68        &self,
69        remote: SocketAddr,
70        requested_user: &str,
71    ) -> Result<SshDecision, crate::Error> {
72        use ts_control::SshDenyReason;
73
74        let Some(peer) = self.peer_by_tailnet_ip(remote.ip()).await? else {
75            tracing::warn!(remote = %remote, "ssh: source IP does not match a known tailnet peer");
76            return Ok(SshDecision::Deny(SshDenyReason::NoRuleMatched));
77        };
78
79        let Some(policy) = self.ssh_policy().await? else {
80            tracing::warn!(remote = %remote, "ssh: no SSH policy pushed by control; deny-all");
81            return Ok(SshDecision::Deny(SshDenyReason::NoRuleMatched));
82        };
83
84        let id = SshConnIdentity {
85            stable_id: peer.stable_id.0.clone(),
86            src_ip: remote.ip(),
87            // The domain node model does not retain the owner login; see method docs.
88            user_login: None,
89        };
90
91        Ok(policy.evaluate_at_unix(&id, requested_user, now_unix_secs()))
92    }
93}
94
95/// Current wall-clock time as Unix seconds, derived from [`std::time::SystemTime`].
96///
97/// The root crate does not depend on `chrono`, and the workspace pins it without the `clock`
98/// feature anyway, so policy evaluation takes a Unix timestamp instead of a `DateTime`. An
99/// unreadable clock (time before the Unix epoch) is clamped to [`i64::MAX`] so SSH-rule expiry
100/// **fails closed**: a broken clock makes every time-limited rule look already-expired (deny)
101/// rather than perpetually-live.
102pub(crate) fn now_unix_secs() -> i64 {
103    std::time::SystemTime::now()
104        .duration_since(std::time::UNIX_EPOCH)
105        .map(|d| d.as_secs() as i64)
106        .unwrap_or(i64::MAX)
107}
108
109/// Format `unix_secs` as ISO 8601 basic date-time in UTC (`20060102T150405`), Go
110/// `tstime.BasicDateTTime`.
111///
112/// Hand-rolled rather than pulled from `chrono`: the root crate deliberately has no `chrono`
113/// dependency (see [`now_unix_secs`]), and this is the only place it would be needed. The
114/// days-to-civil-date conversion is Howard Hinnant's `civil_from_days`, the same algorithm Go's
115/// `time` package uses, valid for any year in the proleptic Gregorian calendar.
116pub(crate) fn basic_date_t_time(unix_secs: i64) -> String {
117    let days = unix_secs.div_euclid(86_400);
118    let secs_of_day = unix_secs.rem_euclid(86_400);
119    let (year, month, day) = civil_from_days(days);
120    format!(
121        "{year:04}{month:02}{day:02}T{:02}{:02}{:02}",
122        secs_of_day / 3600,
123        (secs_of_day % 3600) / 60,
124        secs_of_day % 60,
125    )
126}
127
128/// Convert days since the Unix epoch to a `(year, month, day)` civil date.
129fn civil_from_days(days: i64) -> (i64, u32, u32) {
130    // Shift the epoch to 0000-03-01 so leap days land at the end of the 400-year era.
131    let z = days + 719_468;
132    let era = z.div_euclid(146_097);
133    let doe = z.rem_euclid(146_097);
134    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
135    let y = yoe + era * 400;
136    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
137    let mp = (5 * doy + 2) / 153;
138    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
139    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
140    (if m <= 2 { y + 1 } else { y }, m, d)
141}
142
143/// A fresh SSH connection identifier, shared by every session multiplexed on the connection.
144///
145/// Mirrors Go `tailssh`'s `conn.connID`
146/// (`fmt.Sprintf("ssh-conn-%s-%02x", now.UTC().Format(tstime.BasicDateTTime), randBytes(5))`); it
147/// is the `connectionID` recorded in every session's cast header, so an operator can group the
148/// recordings of one multiplexed connection.
149pub(crate) fn new_conn_id(now_unix: i64) -> String {
150    let rand: [u8; 5] = rand::random();
151    let hex: String = rand.iter().map(|b| format!("{b:02x}")).collect();
152    format!("ssh-conn-{}-{hex}", basic_date_t_time(now_unix))
153}
154
155/// Trait to construct a new [`Handler`] from a Tailscale [`Device`][crate::Device] and
156/// the address of a connecting client.
157///
158/// Rephrasing of [`russh::server::Server`] that includes the Tailscale device as an
159/// argument and skips the support for off-tailnet IP and Unix sockets.
160pub trait TailnetServer {
161    /// Construct a new handler.
162    fn new_client(dev: Arc<crate::Device>, addr: SocketAddr) -> Self;
163}
164
165impl crate::Device {
166    /// Serve an ssh service on the given TCP address.
167    ///
168    /// This is a minimal helper that just wires up the relevant pieces. All the
169    /// authentication and actual SSH server logic must be implemented by the caller in
170    /// the `TailnetServer` (`H`) and configured by `config`.
171    pub async fn serve_ssh<H>(
172        self: Arc<Self>,
173        config: russh::server::Config,
174        listen_addr: SocketAddr,
175    ) -> Result<(), crate::Error>
176    where
177        H: TailnetServer + Handler + Send + 'static,
178        H::Error: Debug,
179    {
180        let config = Arc::new(config);
181        let listener = self.tcp_listen(listen_addr).await?;
182
183        tracing::info!(%listen_addr, "ssh server listening");
184
185        // Bound concurrent connections (back-pressure: acquire a permit *before* accepting so the
186        // loop stops pulling connections off the listener once at the cap). Per-connection sessions
187        // are held in a `JoinSet` owned by this future rather than detached via bare `tokio::spawn`,
188        // so dropping the `serve_ssh` future (the caller's cancellation model) both stops accepting
189        // and aborts in-flight sessions instead of leaking them.
190        let sem = Arc::new(tokio::sync::Semaphore::new(MAX_SSH_CONNECTIONS));
191        let mut sessions = tokio::task::JoinSet::new();
192
193        loop {
194            // Reap finished sessions opportunistically so the `JoinSet` does not grow unbounded.
195            while sessions.try_join_next().is_some() {}
196
197            // The semaphore is never closed in this loop; if it somehow is, stop accepting.
198            let Ok(permit) = sem.clone().acquire_owned().await else {
199                return Ok(());
200            };
201            let conn = listener.accept().await?;
202
203            let handler = H::new_client(self.clone(), conn.remote_addr());
204            let config = config.clone();
205
206            sessions.spawn(async move {
207                // Hold the permit for the connection's lifetime; dropping it on task end frees the
208                // slot for the next accept.
209                let _permit = permit;
210                let sess = match russh::server::run_stream(config, conn, handler).await {
211                    Ok(sess) => sess,
212                    Err(e) => {
213                        tracing::error!(error = ?e, "establishing session");
214                        return;
215                    }
216                };
217
218                match sess.await {
219                    Ok(()) => {}
220                    Err(e) => {
221                        tracing::error!(error = ?e, "running ssh session");
222                    }
223                }
224            });
225        }
226    }
227
228    /// Run a turnkey Tailscale SSH server on `listen_addr` (tailnet overlay) that grants authorized
229    /// connections an interactive login shell as their policy-mapped local user.
230    ///
231    /// Authorization is the control-pushed SSH policy (see [`Device::authorize_ssh`]) — fail-closed:
232    /// unknown source, no policy, no matching rule, or any error rejects. The accepted connection's
233    /// `local_user` is resolved against the local passwd database and the login shell is spawned in
234    /// a PTY **after dropping privileges** to that user's uid/gid (the daemon must run as root to do
235    /// so; if it cannot, the session fails closed). Mirrors Go `tailssh`'s incubator shell path.
236    ///
237    /// Only the interactive login-shell path is implemented: `pty-req` → `<shell> -l`,
238    /// `window-change` → `TIOCSWINSZ`, and an `exit-status` on shell exit. The exec form
239    /// (`<shell> -c <cmd>`) is **not** supported because [`ChannelEvent`] does not surface an SSH
240    /// `exec` request in this fork's channel abstraction.
241    pub async fn listen_ssh(
242        self: Arc<Self>,
243        config: russh::server::Config,
244        listen_addr: SocketAddr,
245    ) -> Result<(), crate::Error> {
246        self.serve_ssh::<ChannelServer<ShellHandler>>(config, listen_addr)
247            .await
248    }
249
250    /// Serve an SSH TUI service on the given TCP address.
251    ///
252    /// Wrapper around [`serve_ssh`][crate::Device::serve_ssh] to specifically use
253    /// [`ChannelServer`] around a [`RatatuiTerm`] using `App`.
254    pub async fn serve_ssh_tui<App>(
255        self: Arc<Self>,
256        config: russh::server::Config,
257        listen_addr: SocketAddr,
258    ) -> Result<(), crate::Error>
259    where
260        App: RatatuiApp + Default + Send + 'static,
261    {
262        self.serve_ssh::<ChannelServer<RatatuiTerm<App>>>(config, listen_addr)
263            .await
264    }
265}
266
267#[cfg(all(test, feature = "ssh"))]
268mod tests {
269    use super::{basic_date_t_time, new_conn_id};
270
271    /// The connection id's timestamp is ISO 8601 basic format in UTC, Go
272    /// `tstime.BasicDateTTime`.
273    #[test]
274    fn basic_date_t_time_formats_utc() {
275        // 2023-11-14T22:13:20Z, the round number 1_700_000_000.
276        assert_eq!(basic_date_t_time(1_700_000_000), "20231114T221320");
277        // The epoch itself, and the day before it (a negative timestamp must not wrap).
278        assert_eq!(basic_date_t_time(0), "19700101T000000");
279        assert_eq!(basic_date_t_time(-1), "19691231T235959");
280        // A leap day, where an off-by-one in the civil-date conversion would show.
281        assert_eq!(basic_date_t_time(1_709_164_800), "20240229T000000");
282        // A century year that IS a leap year (2000 is divisible by 400) has a 29 February.
283        assert_eq!(basic_date_t_time(951_782_400), "20000229T000000");
284        // One that is NOT (1900) does not: 59 days after 1900-01-01 is 1 March, not 29 February.
285        assert_eq!(basic_date_t_time(-2_203_891_200), "19000301T000000");
286    }
287
288    /// The connection id has Go's shape: the `ssh-conn-` prefix, a basic-format timestamp, and 5
289    /// random bytes in hex. Two connections never share one.
290    #[test]
291    fn conn_id_is_prefixed_timestamped_and_unique() {
292        let id = new_conn_id(1_700_000_000);
293        assert!(id.starts_with("ssh-conn-20231114T221320-"), "{id}");
294        let suffix = id.rsplit('-').next().expect("suffix");
295        assert_eq!(suffix.len(), 10, "5 random bytes as hex: {id}");
296        assert!(suffix.chars().all(|c| c.is_ascii_hexdigit()), "{id}");
297        assert_ne!(
298            new_conn_id(1_700_000_000),
299            new_conn_id(1_700_000_000),
300            "each connection must be distinguishable in the recordings"
301        );
302    }
303}