pub extern crate russh;
use std::{fmt::Debug, net::SocketAddr, sync::Arc};
const MAX_SSH_CONNECTIONS: usize = 64;
use russh::server::Handler;
use ts_control::SshConnIdentity;
pub use ts_control::{SshAccept, SshDecision, SshDenyReason, SshPolicy, SshRecorderFailureAction};
mod channel_server;
mod channel_write;
mod ratatui;
pub mod recording;
mod shell;
pub use channel_server::{ChannelContext, ChannelEvent, ChannelHandler, ChannelServer};
pub use ratatui::{RatatuiApp, RatatuiEnv, RatatuiTerm};
pub use shell::ShellHandler;
impl crate::Device {
pub async fn authorize_ssh(
&self,
remote: SocketAddr,
requested_user: &str,
) -> Result<SshDecision, crate::Error> {
use ts_control::SshDenyReason;
let Some(peer) = self.peer_by_tailnet_ip(remote.ip()).await? else {
tracing::warn!(remote = %remote, "ssh: source IP does not match a known tailnet peer");
return Ok(SshDecision::Deny(SshDenyReason::NoRuleMatched));
};
let Some(policy) = self.ssh_policy().await? else {
tracing::warn!(remote = %remote, "ssh: no SSH policy pushed by control; deny-all");
return Ok(SshDecision::Deny(SshDenyReason::NoRuleMatched));
};
let id = SshConnIdentity {
stable_id: peer.stable_id.0.clone(),
src_ip: remote.ip(),
user_login: None,
};
Ok(policy.evaluate_at_unix(&id, requested_user, now_unix_secs()))
}
}
pub(crate) fn now_unix_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(i64::MAX)
}
pub(crate) fn basic_date_t_time(unix_secs: i64) -> String {
let days = unix_secs.div_euclid(86_400);
let secs_of_day = unix_secs.rem_euclid(86_400);
let (year, month, day) = civil_from_days(days);
format!(
"{year:04}{month:02}{day:02}T{:02}{:02}{:02}",
secs_of_day / 3600,
(secs_of_day % 3600) / 60,
secs_of_day % 60,
)
}
fn civil_from_days(days: i64) -> (i64, u32, u32) {
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
(if m <= 2 { y + 1 } else { y }, m, d)
}
pub(crate) fn new_conn_id(now_unix: i64) -> String {
let rand: [u8; 5] = rand::random();
let hex: String = rand.iter().map(|b| format!("{b:02x}")).collect();
format!("ssh-conn-{}-{hex}", basic_date_t_time(now_unix))
}
pub trait TailnetServer {
fn new_client(dev: Arc<crate::Device>, addr: SocketAddr) -> Self;
}
impl crate::Device {
pub async fn serve_ssh<H>(
self: Arc<Self>,
config: russh::server::Config,
listen_addr: SocketAddr,
) -> Result<(), crate::Error>
where
H: TailnetServer + Handler + Send + 'static,
H::Error: Debug,
{
let config = Arc::new(config);
let listener = self.tcp_listen(listen_addr).await?;
tracing::info!(%listen_addr, "ssh server listening");
let sem = Arc::new(tokio::sync::Semaphore::new(MAX_SSH_CONNECTIONS));
let mut sessions = tokio::task::JoinSet::new();
loop {
while sessions.try_join_next().is_some() {}
let Ok(permit) = sem.clone().acquire_owned().await else {
return Ok(());
};
let conn = listener.accept().await?;
let handler = H::new_client(self.clone(), conn.remote_addr());
let config = config.clone();
sessions.spawn(async move {
let _permit = permit;
let sess = match russh::server::run_stream(config, conn, handler).await {
Ok(sess) => sess,
Err(e) => {
tracing::error!(error = ?e, "establishing session");
return;
}
};
match sess.await {
Ok(()) => {}
Err(e) => {
tracing::error!(error = ?e, "running ssh session");
}
}
});
}
}
pub async fn listen_ssh(
self: Arc<Self>,
config: russh::server::Config,
listen_addr: SocketAddr,
) -> Result<(), crate::Error> {
self.serve_ssh::<ChannelServer<ShellHandler>>(config, listen_addr)
.await
}
pub async fn serve_ssh_tui<App>(
self: Arc<Self>,
config: russh::server::Config,
listen_addr: SocketAddr,
) -> Result<(), crate::Error>
where
App: RatatuiApp + Default + Send + 'static,
{
self.serve_ssh::<ChannelServer<RatatuiTerm<App>>>(config, listen_addr)
.await
}
}
#[cfg(all(test, feature = "ssh"))]
mod tests {
use super::{basic_date_t_time, new_conn_id};
#[test]
fn basic_date_t_time_formats_utc() {
assert_eq!(basic_date_t_time(1_700_000_000), "20231114T221320");
assert_eq!(basic_date_t_time(0), "19700101T000000");
assert_eq!(basic_date_t_time(-1), "19691231T235959");
assert_eq!(basic_date_t_time(1_709_164_800), "20240229T000000");
assert_eq!(basic_date_t_time(951_782_400), "20000229T000000");
assert_eq!(basic_date_t_time(-2_203_891_200), "19000301T000000");
}
#[test]
fn conn_id_is_prefixed_timestamped_and_unique() {
let id = new_conn_id(1_700_000_000);
assert!(id.starts_with("ssh-conn-20231114T221320-"), "{id}");
let suffix = id.rsplit('-').next().expect("suffix");
assert_eq!(suffix.len(), 10, "5 random bytes as hex: {id}");
assert!(suffix.chars().all(|c| c.is_ascii_hexdigit()), "{id}");
assert_ne!(
new_conn_id(1_700_000_000),
new_conn_id(1_700_000_000),
"each connection must be distinguishable in the recordings"
);
}
}