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}