Implement the headless SSH server
This commit is contained in:
497
crates/bds-server/src/client.rs
Normal file
497
crates/bds-server/src/client.rs
Normal file
@@ -0,0 +1,497 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use bds_core::model::Project;
|
||||
use russh::ChannelMsg;
|
||||
use russh::client;
|
||||
use russh::keys::key::PrivateKeyWithHashAlg;
|
||||
use russh::keys::{PublicKey, load_secret_key};
|
||||
use serde_json::Value;
|
||||
use tokio::sync::mpsc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::ClientKeyMaterial;
|
||||
use crate::protocol::{Command, PROTOCOL_VERSION, Request, SUBSYSTEM, ServerMessage};
|
||||
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RemoteTarget {
|
||||
pub user: String,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl RemoteTarget {
|
||||
pub fn parse(value: &str) -> Result<Self> {
|
||||
let (user, address) = value
|
||||
.trim()
|
||||
.split_once('@')
|
||||
.filter(|(user, address)| !user.is_empty() && !address.is_empty())
|
||||
.ok_or_else(|| anyhow!("use the form user@host or user@host:port"))?;
|
||||
let (host, port) = if let Some(address) = address.strip_prefix('[') {
|
||||
let (host, suffix) = address
|
||||
.split_once(']')
|
||||
.ok_or_else(|| anyhow!("invalid bracketed host"))?;
|
||||
let port = match suffix.strip_prefix(':') {
|
||||
Some(value) => parse_port(value)?,
|
||||
None if suffix.is_empty() => 2222,
|
||||
None => bail!("invalid host and port"),
|
||||
};
|
||||
(host, port)
|
||||
} else if address.matches(':').count() == 1 {
|
||||
let (host, port) = address.rsplit_once(':').expect("one colon");
|
||||
(host, parse_port(port)?)
|
||||
} else {
|
||||
(address, 2222)
|
||||
};
|
||||
if host.is_empty() {
|
||||
bail!("remote host is required");
|
||||
}
|
||||
Ok(Self {
|
||||
user: user.to_owned(),
|
||||
host: host.to_owned(),
|
||||
port,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn label(&self) -> String {
|
||||
if self.port == 2222 {
|
||||
format!("{}@{}", self.user, self.host)
|
||||
} else if self.host.contains(':') {
|
||||
format!("{}@[{}]:{}", self.user, self.host, self.port)
|
||||
} else {
|
||||
format!("{}@{}:{}", self.user, self.host, self.port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_port(value: &str) -> Result<u16> {
|
||||
value
|
||||
.parse::<u16>()
|
||||
.ok()
|
||||
.filter(|port| *port > 0)
|
||||
.ok_or_else(|| anyhow!("remote SSH port is invalid"))
|
||||
}
|
||||
|
||||
enum ClientCommand {
|
||||
Request {
|
||||
request: Request,
|
||||
response: std::sync::mpsc::Sender<Result<Value, String>>,
|
||||
},
|
||||
Stop,
|
||||
}
|
||||
|
||||
pub struct DesktopClient {
|
||||
target: RemoteTarget,
|
||||
server_locale: String,
|
||||
commands: mpsc::UnboundedSender<ClientCommand>,
|
||||
events: Arc<Mutex<Vec<ServerMessage>>>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DesktopClient {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("DesktopClient")
|
||||
.field("target", &self.target)
|
||||
.field("server_locale", &self.server_locale)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl DesktopClient {
|
||||
pub fn connect(target: RemoteTarget, data_root: &Path) -> Result<Self> {
|
||||
let keys = ClientKeyMaterial::ensure(data_root)?;
|
||||
let (commands, command_rx) = mpsc::unbounded_channel();
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let event_sink = Arc::clone(&events);
|
||||
let error_sink = Arc::clone(&events);
|
||||
let thread_target = target.clone();
|
||||
let (started_tx, started_rx) = std::sync::mpsc::sync_channel(1);
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("bds-remote-client".into())
|
||||
.spawn(move || {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build();
|
||||
let result = match runtime {
|
||||
Ok(runtime) => runtime.block_on(run_client(
|
||||
thread_target,
|
||||
keys,
|
||||
command_rx,
|
||||
event_sink,
|
||||
started_tx,
|
||||
)),
|
||||
Err(error) => {
|
||||
let _ = started_tx.send(Err(error.to_string()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(error) = result {
|
||||
lock(&error_sink).push(ServerMessage::Error {
|
||||
id: String::new(),
|
||||
code: "connection_lost".into(),
|
||||
message: error.to_string(),
|
||||
});
|
||||
}
|
||||
})?;
|
||||
match started_rx.recv_timeout(CONNECT_TIMEOUT) {
|
||||
Ok(Ok(server_locale)) => {
|
||||
let client = Self {
|
||||
target,
|
||||
server_locale,
|
||||
commands,
|
||||
events,
|
||||
thread: Some(thread),
|
||||
};
|
||||
// Prove the selected endpoint speaks the RuDS protocol before
|
||||
// exposing the connection to the desktop.
|
||||
client.list_projects()?;
|
||||
Ok(client)
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
let _ = thread.join();
|
||||
Err(anyhow!(error))
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = commands.send(ClientCommand::Stop);
|
||||
let _ = thread.join();
|
||||
bail!("timed out connecting to the RuDS server")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn target(&self) -> &RemoteTarget {
|
||||
&self.target
|
||||
}
|
||||
|
||||
pub fn server_locale(&self) -> &str {
|
||||
&self.server_locale
|
||||
}
|
||||
|
||||
pub fn list_projects(&self) -> Result<Vec<Project>> {
|
||||
let value = self.request(Command::ListProjects)?;
|
||||
serde_json::from_value(value).context("server returned an invalid project list")
|
||||
}
|
||||
|
||||
pub fn open_project(&self, project_id: &str) -> Result<Project> {
|
||||
let value = self.request(Command::OpenProject {
|
||||
project_id: project_id.to_owned(),
|
||||
})?;
|
||||
serde_json::from_value(value).context("server returned an invalid project")
|
||||
}
|
||||
|
||||
pub fn call(&self, namespace: &str, method: &str, arguments: Vec<Value>) -> Result<Value> {
|
||||
self.request(Command::Call {
|
||||
namespace: namespace.to_owned(),
|
||||
method: method.to_owned(),
|
||||
arguments,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ping(&self) -> Result<()> {
|
||||
self.request(Command::Ping).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn drain_events(&self) -> Vec<ServerMessage> {
|
||||
std::mem::take(&mut *lock(&self.events))
|
||||
}
|
||||
|
||||
pub fn close(&self) {
|
||||
let _ = self.commands.send(ClientCommand::Stop);
|
||||
}
|
||||
|
||||
pub fn disconnect(mut self) -> Result<()> {
|
||||
let _ = self.commands.send(ClientCommand::Stop);
|
||||
if let Some(thread) = self.thread.take() {
|
||||
thread
|
||||
.join()
|
||||
.map_err(|_| anyhow!("remote client thread panicked"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn request(&self, command: Command) -> Result<Value> {
|
||||
let (response, receiver) = std::sync::mpsc::channel();
|
||||
self.commands
|
||||
.send(ClientCommand::Request {
|
||||
request: Request {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
command,
|
||||
},
|
||||
response,
|
||||
})
|
||||
.map_err(|_| anyhow!("remote connection is closed"))?;
|
||||
match receiver.recv_timeout(REQUEST_TIMEOUT) {
|
||||
Ok(Ok(value)) => Ok(value),
|
||||
Ok(Err(error)) => Err(anyhow!(error)),
|
||||
Err(_) => bail!("remote request timed out"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DesktopClient {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.commands.send(ClientCommand::Stop);
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct HostVerifier {
|
||||
host: String,
|
||||
port: u16,
|
||||
path: PathBuf,
|
||||
error: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
impl client::Handler for HostVerifier {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
async fn check_server_key(&mut self, key: &PublicKey) -> Result<bool, Self::Error> {
|
||||
match russh::keys::known_hosts::check_known_hosts_path(
|
||||
&self.host, self.port, key, &self.path,
|
||||
) {
|
||||
Ok(true) => Ok(true),
|
||||
Ok(false) => {
|
||||
russh::keys::known_hosts::learn_known_hosts_path(
|
||||
&self.host, self.port, key, &self.path,
|
||||
)?;
|
||||
set_private_mode(&self.path)?;
|
||||
Ok(true)
|
||||
}
|
||||
Err(error) => {
|
||||
*lock(&self.error) = Some(match error {
|
||||
russh::keys::Error::KeyChanged { .. } => {
|
||||
"the remote server host key changed; remove its known_hosts entry only if the change is trusted".into()
|
||||
}
|
||||
_ => format!("could not verify the remote server host key: {error}"),
|
||||
});
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_client(
|
||||
target: RemoteTarget,
|
||||
keys: ClientKeyMaterial,
|
||||
commands: mpsc::UnboundedReceiver<ClientCommand>,
|
||||
events: Arc<Mutex<Vec<ServerMessage>>>,
|
||||
started: std::sync::mpsc::SyncSender<Result<String, String>>,
|
||||
) -> Result<()> {
|
||||
let verification_error = Arc::new(Mutex::new(None));
|
||||
let verifier = HostVerifier {
|
||||
host: target.host.clone(),
|
||||
port: target.port,
|
||||
path: keys.known_hosts_path.clone(),
|
||||
error: Arc::clone(&verification_error),
|
||||
};
|
||||
let config = Arc::new(client::Config {
|
||||
inactivity_timeout: Some(Duration::from_secs(60 * 60)),
|
||||
nodelay: true,
|
||||
..Default::default()
|
||||
});
|
||||
let mut ssh = match client::connect(config, (target.host.as_str(), target.port), verifier).await
|
||||
{
|
||||
Ok(ssh) => ssh,
|
||||
Err(error) => {
|
||||
let reason = lock(&verification_error)
|
||||
.take()
|
||||
.unwrap_or_else(|| format!("SSH connection failed: {error}"));
|
||||
let _ = started.send(Err(reason.clone()));
|
||||
bail!(reason);
|
||||
}
|
||||
};
|
||||
let private_key = load_secret_key(&keys.private_key_path, None)?;
|
||||
let hash = ssh.best_supported_rsa_hash().await?.flatten();
|
||||
let authentication = ssh
|
||||
.authenticate_publickey(
|
||||
&target.user,
|
||||
PrivateKeyWithHashAlg::new(Arc::new(private_key), hash),
|
||||
)
|
||||
.await?;
|
||||
if !authentication.success() {
|
||||
let message = format!(
|
||||
"public-key authentication failed; add {} to the server authorized_keys file",
|
||||
keys.public_key_path.display()
|
||||
);
|
||||
let _ = started.send(Err(message.clone()));
|
||||
bail!(message);
|
||||
}
|
||||
let mut channel = ssh.channel_open_session().await?;
|
||||
channel.request_subsystem(true, SUBSYSTEM).await?;
|
||||
let hello = Request {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
command: Command::Hello {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
},
|
||||
};
|
||||
let encoded = encode_request(&hello)?;
|
||||
channel.data(&encoded[..]).await?;
|
||||
let mut bytes = Vec::new();
|
||||
loop {
|
||||
let Some(message) = channel.wait().await else {
|
||||
let _ = started.send(Err("server closed during protocol negotiation".into()));
|
||||
bail!("server closed during protocol negotiation");
|
||||
};
|
||||
if let ChannelMsg::Data { data } = message {
|
||||
bytes.extend_from_slice(&data);
|
||||
for message in decode_messages(&mut bytes)? {
|
||||
match message {
|
||||
ServerMessage::Response { id, result } if id == hello.id => {
|
||||
let Some(locale) = result.get("locale").and_then(Value::as_str) else {
|
||||
let message = "server hello did not include its locale".to_owned();
|
||||
let _ = started.send(Err(message.clone()));
|
||||
bail!(message);
|
||||
};
|
||||
let locale = locale.to_owned();
|
||||
let _ = started.send(Ok(locale));
|
||||
return client_loop(ssh, channel, commands, events, bytes).await;
|
||||
}
|
||||
ServerMessage::Error { id, message, .. } if id == hello.id => {
|
||||
let _ = started.send(Err(message.clone()));
|
||||
bail!(message);
|
||||
}
|
||||
other => lock(&events).push(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn client_loop(
|
||||
ssh: client::Handle<HostVerifier>,
|
||||
mut channel: russh::Channel<client::Msg>,
|
||||
mut commands: mpsc::UnboundedReceiver<ClientCommand>,
|
||||
events: Arc<Mutex<Vec<ServerMessage>>>,
|
||||
mut bytes: Vec<u8>,
|
||||
) -> Result<()> {
|
||||
let mut pending = HashMap::<String, std::sync::mpsc::Sender<Result<Value, String>>>::new();
|
||||
loop {
|
||||
tokio::select! {
|
||||
command = commands.recv() => match command {
|
||||
Some(ClientCommand::Request { request, response }) => {
|
||||
pending.insert(request.id.clone(), response);
|
||||
let encoded = encode_request(&request)?;
|
||||
if let Err(error) = channel.data(&encoded[..]).await {
|
||||
fail_pending(&mut pending, &format!("remote connection write failed: {error}"));
|
||||
return Err(error.into());
|
||||
}
|
||||
}
|
||||
Some(ClientCommand::Stop) | None => {
|
||||
let _ = channel.eof().await;
|
||||
let _ = ssh.disconnect(russh::Disconnect::ByApplication, "", "en").await;
|
||||
fail_pending(&mut pending, "remote connection closed");
|
||||
return Ok(());
|
||||
}
|
||||
},
|
||||
message = channel.wait() => match message {
|
||||
Some(ChannelMsg::Data { data }) => {
|
||||
bytes.extend_from_slice(&data);
|
||||
for message in decode_messages(&mut bytes)? {
|
||||
match message {
|
||||
ServerMessage::Response { ref id, ref result } => {
|
||||
if let Some(response) = pending.remove(id) {
|
||||
let _ = response.send(Ok(result.clone()));
|
||||
} else {
|
||||
lock(&events).push(message);
|
||||
}
|
||||
}
|
||||
ServerMessage::Error { ref id, message: ref error_message, .. } => {
|
||||
if let Some(response) = pending.remove(id) {
|
||||
let _ = response.send(Err(error_message.clone()));
|
||||
} else {
|
||||
lock(&events).push(message.clone());
|
||||
}
|
||||
}
|
||||
other => lock(&events).push(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(ChannelMsg::Close | ChannelMsg::Eof) | None => {
|
||||
fail_pending(&mut pending, "remote server disconnected");
|
||||
bail!("remote server disconnected");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_request(request: &Request) -> Result<Vec<u8>> {
|
||||
let mut bytes = serde_json::to_vec(request)?;
|
||||
bytes.push(b'\n');
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn decode_messages(bytes: &mut Vec<u8>) -> Result<Vec<ServerMessage>> {
|
||||
let mut messages = Vec::new();
|
||||
while let Some(newline) = bytes.iter().position(|byte| *byte == b'\n') {
|
||||
let line = bytes.drain(..=newline).collect::<Vec<_>>();
|
||||
if line.len() > 1 {
|
||||
messages.push(serde_json::from_slice(&line[..line.len() - 1])?);
|
||||
}
|
||||
}
|
||||
if bytes.len() > 1024 * 1024 {
|
||||
bail!("remote response exceeds 1 MiB");
|
||||
}
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
fn fail_pending(
|
||||
pending: &mut HashMap<String, std::sync::mpsc::Sender<Result<Value, String>>>,
|
||||
message: &str,
|
||||
) {
|
||||
for (_, response) in pending.drain() {
|
||||
let _ = response.send(Err(message.to_owned()));
|
||||
}
|
||||
}
|
||||
|
||||
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
mutex
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_private_mode(path: &Path) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn set_private_mode(_path: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_default_explicit_and_ipv6_targets() {
|
||||
assert_eq!(
|
||||
RemoteTarget::parse(" gb@blog.example ").unwrap(),
|
||||
RemoteTarget {
|
||||
user: "gb".into(),
|
||||
host: "blog.example".into(),
|
||||
port: 2222,
|
||||
}
|
||||
);
|
||||
assert_eq!(RemoteTarget::parse("gb@host:2022").unwrap().port, 2022);
|
||||
assert_eq!(RemoteTarget::parse("gb@[::1]:2200").unwrap().host, "::1");
|
||||
assert!(RemoteTarget::parse("host").is_err());
|
||||
assert!(RemoteTarget::parse("@host").is_err());
|
||||
assert!(RemoteTarget::parse("user@host:nope").is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user