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}