Implement the headless SSH server
This commit is contained in:
317
crates/bds-server/src/auth.rs
Normal file
317
crates/bds-server/src/auth.rs
Normal file
@@ -0,0 +1,317 @@
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use russh::keys::key::safe_rng;
|
||||
use russh::keys::{Algorithm, PrivateKey, PublicKey, load_secret_key, ssh_key};
|
||||
|
||||
pub const HOST_KEY_FILE: &str = "ssh_host_rsa_key";
|
||||
pub const AUTHORIZED_KEYS_FILE: &str = "authorized_keys";
|
||||
pub const CLIENT_KEY_FILE: &str = "id_ed25519";
|
||||
pub const KNOWN_HOSTS_FILE: &str = "known_hosts";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KeyMaterial {
|
||||
pub directory: PathBuf,
|
||||
pub host_key_path: PathBuf,
|
||||
pub authorized_keys_path: PathBuf,
|
||||
}
|
||||
|
||||
impl KeyMaterial {
|
||||
pub fn ensure(data_dir: &Path) -> Result<Self> {
|
||||
let directory = data_dir.join("ssh");
|
||||
ensure_private_directory(&directory)?;
|
||||
let host_key_path = directory.join(HOST_KEY_FILE);
|
||||
let authorized_keys_path = directory.join(AUTHORIZED_KEYS_FILE);
|
||||
ensure_private_file(&authorized_keys_path, b"")?;
|
||||
if !host_key_path.exists() {
|
||||
let key = PrivateKey::random(&mut safe_rng(), Algorithm::Rsa { hash: None })
|
||||
.context("could not generate the SSH host key")?;
|
||||
let encoded = key
|
||||
.to_openssh(ssh_key::LineEnding::LF)
|
||||
.context("could not encode the SSH host key")?;
|
||||
ensure_private_file(&host_key_path, encoded.as_bytes())?;
|
||||
}
|
||||
validate_private_file(&host_key_path)?;
|
||||
validate_private_file(&authorized_keys_path)?;
|
||||
load_secret_key(&host_key_path, None)
|
||||
.with_context(|| format!("could not read SSH host key {}", host_key_path.display()))?;
|
||||
Ok(Self {
|
||||
directory,
|
||||
host_key_path,
|
||||
authorized_keys_path,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn host_key(&self) -> Result<PrivateKey> {
|
||||
validate_private_file(&self.host_key_path)?;
|
||||
load_secret_key(&self.host_key_path, None).with_context(|| {
|
||||
format!(
|
||||
"could not read SSH host key {}",
|
||||
self.host_key_path.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Re-reads the file for every authentication attempt, so removing a key
|
||||
/// revokes it without restarting the server.
|
||||
pub fn authorizes(&self, candidate: &PublicKey) -> Result<bool> {
|
||||
validate_private_file(&self.authorized_keys_path)?;
|
||||
let contents = fs::read_to_string(&self.authorized_keys_path).with_context(|| {
|
||||
format!(
|
||||
"could not read authorized keys {}",
|
||||
self.authorized_keys_path.display()
|
||||
)
|
||||
})?;
|
||||
for (index, line) in contents.lines().enumerate() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let fields = line.split_whitespace().collect::<Vec<_>>();
|
||||
let key_start = fields
|
||||
.iter()
|
||||
.position(|field| {
|
||||
field.starts_with("ssh-")
|
||||
|| field.starts_with("ecdsa-")
|
||||
|| field.starts_with("sk-")
|
||||
})
|
||||
.filter(|index| fields.get(index + 1).is_some());
|
||||
let encoded = key_start
|
||||
.map(|index| format!("{} {}", fields[index], fields[index + 1]))
|
||||
.unwrap_or_else(|| line.to_owned());
|
||||
let key = PublicKey::from_openssh(&encoded).with_context(|| {
|
||||
format!(
|
||||
"invalid authorized key at {}:{}",
|
||||
self.authorized_keys_path.display(),
|
||||
index + 1
|
||||
)
|
||||
})?;
|
||||
if key.key_data() == candidate.key_data() {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClientKeyMaterial {
|
||||
pub directory: PathBuf,
|
||||
pub private_key_path: PathBuf,
|
||||
pub public_key_path: PathBuf,
|
||||
pub known_hosts_path: PathBuf,
|
||||
}
|
||||
|
||||
impl ClientKeyMaterial {
|
||||
pub fn ensure(data_dir: &Path) -> Result<Self> {
|
||||
let directory = data_dir.join("ssh");
|
||||
ensure_private_directory(&directory)?;
|
||||
let private_key_path = directory.join(CLIENT_KEY_FILE);
|
||||
let public_key_path = directory.join(format!("{CLIENT_KEY_FILE}.pub"));
|
||||
let known_hosts_path = directory.join(KNOWN_HOSTS_FILE);
|
||||
ensure_private_file(&known_hosts_path, b"")?;
|
||||
if !private_key_path.exists() {
|
||||
let mut key = PrivateKey::random(&mut safe_rng(), Algorithm::Ed25519)
|
||||
.context("could not generate the SSH client identity")?;
|
||||
key.set_comment("ruds-desktop");
|
||||
let private = key
|
||||
.to_openssh(ssh_key::LineEnding::LF)
|
||||
.context("could not encode the SSH client identity")?;
|
||||
ensure_private_file(&private_key_path, private.as_bytes())?;
|
||||
let public = format!("{}\n", key.public_key().to_openssh()?);
|
||||
ensure_public_file(&public_key_path, public.as_bytes())?;
|
||||
}
|
||||
validate_private_file(&private_key_path)?;
|
||||
validate_private_file(&known_hosts_path)?;
|
||||
let key = load_secret_key(&private_key_path, None).with_context(|| {
|
||||
format!(
|
||||
"could not read SSH client identity {}",
|
||||
private_key_path.display()
|
||||
)
|
||||
})?;
|
||||
let public = format!("{}\n", key.public_key().to_openssh()?);
|
||||
ensure_public_file(&public_key_path, public.as_bytes())?;
|
||||
Ok(Self {
|
||||
directory,
|
||||
private_key_path,
|
||||
public_key_path,
|
||||
known_hosts_path,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_private_directory(path: &Path) -> Result<()> {
|
||||
if !path.exists() {
|
||||
fs::create_dir_all(path).with_context(|| {
|
||||
format!("could not create private SSH directory {}", path.display())
|
||||
})?;
|
||||
set_mode(path, 0o700)?;
|
||||
}
|
||||
let metadata = fs::symlink_metadata(path)?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
bail!("SSH key directory is not a directory: {}", path.display());
|
||||
}
|
||||
validate_mode(path, 0o700, "SSH key directory")
|
||||
}
|
||||
|
||||
fn ensure_private_file(path: &Path, contents: &[u8]) -> Result<()> {
|
||||
if path.exists() {
|
||||
return validate_private_file(path);
|
||||
}
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
options.mode(0o600);
|
||||
}
|
||||
let mut file = options
|
||||
.open(path)
|
||||
.with_context(|| format!("could not create private SSH file {}", path.display()))?;
|
||||
file.write_all(contents)?;
|
||||
file.sync_all()?;
|
||||
set_mode(path, 0o600)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_public_file(path: &Path, contents: &[u8]) -> Result<()> {
|
||||
if path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut file = OpenOptions::new().write(true).create_new(true).open(path)?;
|
||||
file.write_all(contents)?;
|
||||
file.sync_all()?;
|
||||
set_mode(path, 0o644)
|
||||
}
|
||||
|
||||
fn validate_private_file(path: &Path) -> Result<()> {
|
||||
let metadata = fs::symlink_metadata(path)?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
bail!(
|
||||
"SSH key file is missing or not a regular file: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
validate_mode(path, 0o600, "SSH key file")
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn validate_mode(path: &Path, maximum: u32, label: &str) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let mode = fs::metadata(path)?.permissions().mode() & 0o777;
|
||||
if mode & !maximum != 0 {
|
||||
bail!(
|
||||
"unsafe permissions {mode:o} on {label} {}; expected {maximum:o}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn validate_mode(_path: &Path, _maximum: u32, _label: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_mode(path: &Path, mode: u32) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(mode))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn set_mode(_path: &Path, _mode: u32) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn creates_and_reuses_restrictive_server_key_material() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let first = KeyMaterial::ensure(root.path()).unwrap();
|
||||
let bytes = fs::read(&first.host_key_path).unwrap();
|
||||
assert_eq!(
|
||||
first.host_key().unwrap().algorithm(),
|
||||
Algorithm::Rsa { hash: None }
|
||||
);
|
||||
assert_eq!(fs::read_to_string(&first.authorized_keys_path).unwrap(), "");
|
||||
let second = KeyMaterial::ensure(root.path()).unwrap();
|
||||
assert_eq!(fs::read(second.host_key_path).unwrap(), bytes);
|
||||
assert_private(&first.directory, 0o700);
|
||||
assert_private(&first.host_key_path, 0o600);
|
||||
assert_private(&first.authorized_keys_path, 0o600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorized_keys_accept_reject_and_revoke_immediately() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let material = KeyMaterial::ensure(root.path()).unwrap();
|
||||
let allowed = PrivateKey::random(&mut safe_rng(), Algorithm::Ed25519).unwrap();
|
||||
let unknown = PrivateKey::random(&mut safe_rng(), Algorithm::Ed25519).unwrap();
|
||||
fs::write(
|
||||
&material.authorized_keys_path,
|
||||
format!(
|
||||
"# desktop\nrestrict {} user@desktop\n",
|
||||
allowed.public_key().to_openssh().unwrap()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(material.authorizes(allowed.public_key()).unwrap());
|
||||
assert!(!material.authorizes(unknown.public_key()).unwrap());
|
||||
fs::write(&material.authorized_keys_path, "").unwrap();
|
||||
assert!(!material.authorizes(allowed.public_key()).unwrap());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unsafe_key_files_are_rejected_with_the_path_and_mode() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let material = KeyMaterial::ensure(root.path()).unwrap();
|
||||
fs::set_permissions(
|
||||
&material.authorized_keys_path,
|
||||
fs::Permissions::from_mode(0o644),
|
||||
)
|
||||
.unwrap();
|
||||
let error = material
|
||||
.authorizes(
|
||||
PrivateKey::random(&mut safe_rng(), Algorithm::Ed25519)
|
||||
.unwrap()
|
||||
.public_key(),
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("unsafe permissions 644"));
|
||||
assert!(error.contains(AUTHORIZED_KEYS_FILE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_a_reusable_desktop_identity_and_known_hosts() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let first = ClientKeyMaterial::ensure(root.path()).unwrap();
|
||||
let bytes = fs::read(&first.private_key_path).unwrap();
|
||||
fs::remove_file(&first.public_key_path).unwrap();
|
||||
let second = ClientKeyMaterial::ensure(root.path()).unwrap();
|
||||
assert_eq!(fs::read(second.private_key_path).unwrap(), bytes);
|
||||
assert!(first.public_key_path.is_file());
|
||||
assert_private(&first.private_key_path, 0o600);
|
||||
assert_private(&first.known_hosts_path, 0o600);
|
||||
}
|
||||
|
||||
fn assert_private(path: &Path, expected: u32) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
assert_eq!(
|
||||
fs::metadata(path).unwrap().permissions().mode() & 0o777,
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
121
crates/bds-server/src/boot.rs
Normal file
121
crates/bds-server/src/boot.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BootMode {
|
||||
Desktop,
|
||||
Server,
|
||||
Tui,
|
||||
}
|
||||
|
||||
impl BootMode {
|
||||
pub fn resolve(value: Option<&str>) -> Self {
|
||||
match value.map(str::trim).map(str::to_ascii_lowercase).as_deref() {
|
||||
Some("server") => Self::Server,
|
||||
Some("tui") => Self::Tui,
|
||||
_ => Self::Desktop,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn effective(self, platform: Platform, env: &HashMap<String, String>) -> Self {
|
||||
if self != Self::Desktop {
|
||||
return self;
|
||||
}
|
||||
match platform {
|
||||
Platform::MacOs if has_any(env, &["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"]) => {
|
||||
Self::Tui
|
||||
}
|
||||
Platform::Unix if !has_any(env, &["DISPLAY", "WAYLAND_DISPLAY"]) => Self::Tui,
|
||||
_ => Self::Desktop,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn components(self) -> BootComponents {
|
||||
match self {
|
||||
Self::Desktop => BootComponents {
|
||||
engine_host: true,
|
||||
ssh_daemon: false,
|
||||
desktop_ui: true,
|
||||
local_tui: false,
|
||||
},
|
||||
Self::Server => BootComponents {
|
||||
engine_host: true,
|
||||
ssh_daemon: true,
|
||||
desktop_ui: false,
|
||||
local_tui: false,
|
||||
},
|
||||
Self::Tui => BootComponents {
|
||||
engine_host: true,
|
||||
ssh_daemon: true,
|
||||
desktop_ui: false,
|
||||
local_tui: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Platform {
|
||||
MacOs,
|
||||
Unix,
|
||||
Windows,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct BootComponents {
|
||||
pub engine_host: bool,
|
||||
pub ssh_daemon: bool,
|
||||
pub desktop_ui: bool,
|
||||
pub local_tui: bool,
|
||||
}
|
||||
|
||||
fn has_any(env: &HashMap<String, String>, keys: &[&str]) -> bool {
|
||||
keys.iter()
|
||||
.any(|key| env.get(*key).is_some_and(|value| !value.is_empty()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn modes_resolve_and_initialize_only_their_components() {
|
||||
assert_eq!(BootMode::resolve(None), BootMode::Desktop);
|
||||
assert_eq!(BootMode::resolve(Some("SERVER")), BootMode::Server);
|
||||
assert_eq!(BootMode::resolve(Some("tui")), BootMode::Tui);
|
||||
assert!(BootMode::Desktop.components().desktop_ui);
|
||||
assert!(!BootMode::Server.components().desktop_ui);
|
||||
assert!(!BootMode::Tui.components().desktop_ui);
|
||||
assert!(BootMode::Tui.components().local_tui);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_falls_back_only_when_the_platform_is_headless() {
|
||||
let empty = HashMap::new();
|
||||
assert_eq!(
|
||||
BootMode::Desktop.effective(Platform::Unix, &empty),
|
||||
BootMode::Tui
|
||||
);
|
||||
assert_eq!(
|
||||
BootMode::Desktop.effective(Platform::MacOs, &empty),
|
||||
BootMode::Desktop
|
||||
);
|
||||
assert_eq!(
|
||||
BootMode::Desktop.effective(Platform::Windows, &empty),
|
||||
BootMode::Desktop
|
||||
);
|
||||
let display = HashMap::from([("DISPLAY".into(), ":0".into())]);
|
||||
assert_eq!(
|
||||
BootMode::Desktop.effective(Platform::Unix, &display),
|
||||
BootMode::Desktop
|
||||
);
|
||||
let ssh = HashMap::from([("SSH_TTY".into(), "/dev/tty".into())]);
|
||||
assert_eq!(
|
||||
BootMode::Desktop.effective(Platform::MacOs, &ssh),
|
||||
BootMode::Tui
|
||||
);
|
||||
assert_eq!(
|
||||
BootMode::Server.effective(Platform::Unix, &empty),
|
||||
BootMode::Server
|
||||
);
|
||||
}
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
695
crates/bds-server/src/host.rs
Normal file
695
crates/bds-server/src/host.rs
Normal file
@@ -0,0 +1,695 @@
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use bds_core::db::Database;
|
||||
use bds_core::engine::domain_events::EventSubscription;
|
||||
use bds_core::engine::task::{TaskManager, TaskSnapshot, TaskStatus};
|
||||
use bds_core::engine::{domain_events, project, settings};
|
||||
use bds_core::scripting::{CoreHost, HostApi};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::protocol::{Command, PROTOCOL_VERSION, RemoteTask, Request, ServerMessage};
|
||||
|
||||
pub fn run_local_terminal(host: ApplicationHost) -> Result<()> {
|
||||
use std::io::{BufRead as _, Write as _};
|
||||
|
||||
let mut session = host.session()?;
|
||||
let _ = session.handle(Request {
|
||||
id: "local-terminal-hello".into(),
|
||||
command: Command::Hello {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
},
|
||||
});
|
||||
let projects = session.handle(Request {
|
||||
id: "local-terminal-projects".into(),
|
||||
command: Command::ListProjects,
|
||||
});
|
||||
let names = match projects {
|
||||
ServerMessage::Response { result, .. } => result
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|project| project.get("name").and_then(Value::as_str))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n • "),
|
||||
_ => String::new(),
|
||||
};
|
||||
let locale = bds_core::i18n::normalize_language(session.locale());
|
||||
let title = bds_core::i18n::translate(locale, "remoteTerminal.localTitle");
|
||||
let available = bds_core::i18n::translate(locale, "remoteTerminal.availableProjects");
|
||||
let quit = bds_core::i18n::translate(locale, "remoteTerminal.quit");
|
||||
println!(
|
||||
"\x1b[2J\x1b[H{title}\n\n{available}:\n • {}\n\n{quit}",
|
||||
if names.is_empty() { "—" } else { &names }
|
||||
);
|
||||
std::io::stdout().flush()?;
|
||||
for line in std::io::stdin().lock().lines() {
|
||||
if line?.trim().eq_ignore_ascii_case("q") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ApplicationHost {
|
||||
inner: Arc<HostInner>,
|
||||
}
|
||||
|
||||
struct HostInner {
|
||||
database_path: PathBuf,
|
||||
data_root: PathBuf,
|
||||
tasks: Arc<TaskManager>,
|
||||
sync_watcher: SyncWatcher,
|
||||
completed_requests: Mutex<HashMap<String, ServerMessage>>,
|
||||
execution_lock: Mutex<()>,
|
||||
}
|
||||
|
||||
struct SyncWatcher {
|
||||
shutdown: mpsc::Sender<()>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
errors: Arc<Mutex<WatcherErrors>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct WatcherErrors {
|
||||
next_id: u64,
|
||||
entries: VecDeque<(u64, String)>,
|
||||
}
|
||||
|
||||
impl SyncWatcher {
|
||||
fn start(database_path: PathBuf) -> Result<Self> {
|
||||
let (shutdown, shutdown_rx) = mpsc::channel();
|
||||
let (started_tx, started_rx) = mpsc::sync_channel(1);
|
||||
let errors = Arc::new(Mutex::new(WatcherErrors::default()));
|
||||
let thread_errors = Arc::clone(&errors);
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("bds-cli-sync-watcher".into())
|
||||
.spawn(move || {
|
||||
let database = match Database::open(&database_path) {
|
||||
Ok(database) => database,
|
||||
Err(error) => {
|
||||
let _ = started_tx.send(Err(error.to_string()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = started_tx.send(Ok(()));
|
||||
loop {
|
||||
match shutdown_rx.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||
if let Err(error) =
|
||||
bds_core::engine::cli_sync::poll_notifications(database.conn())
|
||||
{
|
||||
let mut errors = lock(&thread_errors);
|
||||
let message = error.to_string();
|
||||
if errors
|
||||
.entries
|
||||
.back()
|
||||
.is_some_and(|(_, last)| last == &message)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
errors.next_id += 1;
|
||||
let id = errors.next_id;
|
||||
errors.entries.push_back((id, message));
|
||||
if errors.entries.len() > 128 {
|
||||
errors.entries.pop_front();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})?;
|
||||
match started_rx.recv() {
|
||||
Ok(Ok(())) => Ok(Self {
|
||||
shutdown,
|
||||
thread: Some(thread),
|
||||
errors,
|
||||
}),
|
||||
Ok(Err(error)) => {
|
||||
let _ = thread.join();
|
||||
Err(anyhow!("could not start the CLI sync watcher: {error}"))
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = thread.join();
|
||||
Err(anyhow!("the CLI sync watcher stopped during startup"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn errors_since(&self, id: u64) -> Vec<(u64, String)> {
|
||||
lock(&self.errors)
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|(entry_id, _)| *entry_id > id)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SyncWatcher {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.shutdown.send(());
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ApplicationHost {
|
||||
pub fn start(database_path: PathBuf, data_root: PathBuf) -> Result<Self> {
|
||||
if let Some(parent) = database_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let db = Database::open(&database_path)?;
|
||||
db.migrate()
|
||||
.map_err(|error| anyhow!("could not migrate the application database: {error}"))?;
|
||||
bds_core::engine::search::prepare_search_index(db.conn())?;
|
||||
let sync_watcher = SyncWatcher::start(database_path.clone())?;
|
||||
Ok(Self {
|
||||
inner: Arc::new(HostInner {
|
||||
database_path,
|
||||
data_root,
|
||||
tasks: Arc::new(TaskManager::default()),
|
||||
sync_watcher,
|
||||
completed_requests: Mutex::new(HashMap::new()),
|
||||
execution_lock: Mutex::new(()),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tasks(&self) -> Arc<TaskManager> {
|
||||
Arc::clone(&self.inner.tasks)
|
||||
}
|
||||
|
||||
pub fn session(&self) -> Result<ApplicationSession> {
|
||||
let db = self.database()?;
|
||||
let locale = settings::ui_language(db.conn())?
|
||||
.map(|value| bds_core::i18n::normalize_language(&value))
|
||||
.unwrap_or_else(bds_core::i18n::detect_os_locale)
|
||||
.code()
|
||||
.to_owned();
|
||||
Ok(ApplicationSession {
|
||||
host: self.clone(),
|
||||
selected_project: None,
|
||||
negotiated: false,
|
||||
locale,
|
||||
sequence: 0,
|
||||
events: domain_events::subscribe(),
|
||||
last_tasks: Vec::new(),
|
||||
last_watcher_error: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn database(&self) -> Result<Database> {
|
||||
Database::open(&self.inner.database_path).map_err(Into::into)
|
||||
}
|
||||
|
||||
fn cached(&self, id: &str) -> Option<ServerMessage> {
|
||||
lock(&self.inner.completed_requests).get(id).cloned()
|
||||
}
|
||||
|
||||
fn remember(&self, id: String, response: ServerMessage) {
|
||||
let mut completed = lock(&self.inner.completed_requests);
|
||||
if completed.len() >= 4_096
|
||||
&& let Some(oldest) = completed.keys().next().cloned()
|
||||
{
|
||||
completed.remove(&oldest);
|
||||
}
|
||||
completed.insert(id, response);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ApplicationSession {
|
||||
host: ApplicationHost,
|
||||
selected_project: Option<SelectedProject>,
|
||||
negotiated: bool,
|
||||
locale: String,
|
||||
sequence: u64,
|
||||
events: EventSubscription,
|
||||
last_tasks: Vec<RemoteTask>,
|
||||
last_watcher_error: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SelectedProject {
|
||||
id: String,
|
||||
data_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl ApplicationSession {
|
||||
pub fn locale(&self) -> &str {
|
||||
&self.locale
|
||||
}
|
||||
|
||||
pub fn handle(&mut self, request: Request) -> ServerMessage {
|
||||
let idempotent = matches!(request.command, Command::Call { .. });
|
||||
if idempotent {
|
||||
let inner = Arc::clone(&self.host.inner);
|
||||
let _execution = lock(&inner.execution_lock);
|
||||
if let Some(response) = self.host.cached(&request.id) {
|
||||
return response;
|
||||
}
|
||||
let id = request.id;
|
||||
let response = self.response(id.clone(), &request.command);
|
||||
self.host.remember(id, response.clone());
|
||||
return response;
|
||||
}
|
||||
let id = request.id;
|
||||
self.response(id, &request.command)
|
||||
}
|
||||
|
||||
fn response(&mut self, id: String, command: &Command) -> ServerMessage {
|
||||
match self.execute(command) {
|
||||
Ok(result) => ServerMessage::Response { id, result },
|
||||
Err(error) => ServerMessage::Error {
|
||||
id,
|
||||
code: error.code.to_owned(),
|
||||
message: error.message,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pending(&mut self) -> Vec<ServerMessage> {
|
||||
let mut messages = self
|
||||
.events
|
||||
.drain()
|
||||
.into_iter()
|
||||
.filter(|event| {
|
||||
event.project_id().is_none()
|
||||
|| self
|
||||
.selected_project
|
||||
.as_ref()
|
||||
.is_some_and(|project| event.project_id() == Some(project.id.as_str()))
|
||||
})
|
||||
.map(|event| {
|
||||
self.sequence += 1;
|
||||
ServerMessage::Event {
|
||||
sequence: self.sequence,
|
||||
event,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let tasks = self
|
||||
.host
|
||||
.inner
|
||||
.tasks
|
||||
.snapshots()
|
||||
.into_iter()
|
||||
.map(remote_task)
|
||||
.collect::<Vec<_>>();
|
||||
if tasks != self.last_tasks {
|
||||
self.last_tasks.clone_from(&tasks);
|
||||
self.sequence += 1;
|
||||
messages.push(ServerMessage::Tasks {
|
||||
sequence: self.sequence,
|
||||
tasks,
|
||||
});
|
||||
}
|
||||
for (id, message) in self
|
||||
.host
|
||||
.inner
|
||||
.sync_watcher
|
||||
.errors_since(self.last_watcher_error)
|
||||
{
|
||||
self.last_watcher_error = id;
|
||||
messages.push(ServerMessage::Error {
|
||||
id: String::new(),
|
||||
code: "sync_watcher_error".into(),
|
||||
message,
|
||||
});
|
||||
}
|
||||
messages
|
||||
}
|
||||
|
||||
fn execute(&mut self, command: &Command) -> Result<Value, ProtocolError> {
|
||||
if !matches!(command, Command::Hello { .. }) && !self.negotiated {
|
||||
return Err(ProtocolError::new(
|
||||
"protocol_required",
|
||||
"hello must be the first request",
|
||||
));
|
||||
}
|
||||
match command {
|
||||
Command::Hello { protocol_version } => {
|
||||
if *protocol_version != PROTOCOL_VERSION {
|
||||
return Err(ProtocolError::new(
|
||||
"unsupported_protocol",
|
||||
format!(
|
||||
"unsupported remote protocol {protocol_version}; server requires {PROTOCOL_VERSION}"
|
||||
),
|
||||
));
|
||||
}
|
||||
self.negotiated = true;
|
||||
Ok(json!({
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"server_name": "Blogging Desktop Server",
|
||||
"locale": self.locale,
|
||||
}))
|
||||
}
|
||||
Command::ListProjects => {
|
||||
let db = self.host.database().map_err(ProtocolError::engine)?;
|
||||
let projects = project::list_projects(db.conn()).map_err(ProtocolError::engine)?;
|
||||
serde_json::to_value(projects).map_err(ProtocolError::engine)
|
||||
}
|
||||
Command::OpenProject { project_id } => {
|
||||
let db = self.host.database().map_err(ProtocolError::engine)?;
|
||||
let value =
|
||||
bds_core::db::queries::project::get_project_by_id(db.conn(), project_id)
|
||||
.map_err(|_| {
|
||||
ProtocolError::new(
|
||||
"project_not_found",
|
||||
format!("project '{project_id}' was not found on the server"),
|
||||
)
|
||||
})?;
|
||||
let data_dir = value
|
||||
.data_path
|
||||
.as_deref()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| self.host.inner.data_root.join("projects").join(&value.id));
|
||||
if !data_dir.join("meta/project.json").is_file() {
|
||||
return Err(ProtocolError::new(
|
||||
"project_unavailable",
|
||||
format!("project '{}' data is unavailable", value.name),
|
||||
));
|
||||
}
|
||||
self.selected_project = Some(SelectedProject {
|
||||
id: value.id.clone(),
|
||||
data_dir,
|
||||
});
|
||||
serde_json::to_value(value).map_err(ProtocolError::engine)
|
||||
}
|
||||
Command::Call {
|
||||
namespace,
|
||||
method,
|
||||
arguments,
|
||||
} => {
|
||||
let selected = self.selected_project.as_ref().ok_or_else(|| {
|
||||
ProtocolError::new("project_required", "open a remote project first")
|
||||
})?;
|
||||
CoreHost::new(
|
||||
&self.host.inner.database_path,
|
||||
&selected.id,
|
||||
&selected.data_dir,
|
||||
)
|
||||
.with_task_manager(Arc::clone(&self.host.inner.tasks))
|
||||
.call(namespace, method, arguments.clone())
|
||||
.map_err(|message| ProtocolError::new("engine_error", message))
|
||||
}
|
||||
Command::Ping => Ok(json!({"ok": true})),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProtocolError {
|
||||
code: &'static str,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl ProtocolError {
|
||||
fn new(code: &'static str, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn engine(error: impl std::fmt::Display) -> Self {
|
||||
Self::new("engine_error", error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_task(snapshot: TaskSnapshot) -> RemoteTask {
|
||||
let (status, failure) = match snapshot.status {
|
||||
TaskStatus::Pending => ("pending", None),
|
||||
TaskStatus::Running => ("running", None),
|
||||
TaskStatus::Completed => ("completed", None),
|
||||
TaskStatus::Failed(message) => ("failed", Some(message)),
|
||||
TaskStatus::Cancelled => ("cancelled", None),
|
||||
};
|
||||
RemoteTask {
|
||||
id: snapshot.id,
|
||||
label: snapshot.label,
|
||||
status: status.to_owned(),
|
||||
progress: snapshot.progress,
|
||||
message: snapshot.message.or(failure),
|
||||
}
|
||||
}
|
||||
|
||||
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
mutex
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::protocol::{Command, Request};
|
||||
|
||||
struct Fixture {
|
||||
_root: tempfile::TempDir,
|
||||
host: ApplicationHost,
|
||||
project_id: String,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let database_path = root.path().join("bds.db");
|
||||
let data_root = root.path().join("data");
|
||||
let host = ApplicationHost::start(database_path.clone(), data_root.clone()).unwrap();
|
||||
let db = Database::open(&database_path).unwrap();
|
||||
let project_dir = root.path().join("blog");
|
||||
let value = project::create_project(
|
||||
db.conn(),
|
||||
"Remote Blog",
|
||||
Some(project_dir.to_str().unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
settings::set(db.conn(), settings::UI_LANGUAGE_KEY, "de").unwrap();
|
||||
Self {
|
||||
_root: root,
|
||||
host,
|
||||
project_id: value.id,
|
||||
}
|
||||
}
|
||||
|
||||
fn session(&self) -> ApplicationSession {
|
||||
let mut session = self.host.session().unwrap();
|
||||
assert!(matches!(
|
||||
session.handle(request(
|
||||
"hello",
|
||||
Command::Hello {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
}
|
||||
)),
|
||||
ServerMessage::Response { .. }
|
||||
));
|
||||
session
|
||||
}
|
||||
}
|
||||
|
||||
fn request(id: &str, command: Command) -> Request {
|
||||
Request {
|
||||
id: id.to_owned(),
|
||||
command,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_negotiates_server_locale_and_selects_a_project() {
|
||||
let fixture = Fixture::new();
|
||||
let mut session = fixture.host.session().unwrap();
|
||||
assert_eq!(session.locale(), "de");
|
||||
assert!(matches!(
|
||||
session.handle(request("early", Command::ListProjects)),
|
||||
ServerMessage::Error { ref code, .. } if code == "protocol_required"
|
||||
));
|
||||
let mut session = fixture.session();
|
||||
let listed = session.handle(request("list", Command::ListProjects));
|
||||
assert!(matches!(listed, ServerMessage::Response { .. }));
|
||||
let opened = session.handle(request(
|
||||
"open",
|
||||
Command::OpenProject {
|
||||
project_id: fixture.project_id.clone(),
|
||||
},
|
||||
));
|
||||
assert!(matches!(opened, ServerMessage::Response { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_clients_observe_one_ordered_mutation_and_request_replay_is_idempotent() {
|
||||
let fixture = Fixture::new();
|
||||
let mut first = fixture.session();
|
||||
let mut second = fixture.session();
|
||||
for (index, session) in [&mut first, &mut second].into_iter().enumerate() {
|
||||
session.handle(request(
|
||||
&format!("open-{index}"),
|
||||
Command::OpenProject {
|
||||
project_id: fixture.project_id.clone(),
|
||||
},
|
||||
));
|
||||
let _ = session.pending();
|
||||
}
|
||||
let create = request(
|
||||
"globally-unique-create",
|
||||
Command::Call {
|
||||
namespace: "posts".into(),
|
||||
method: "create".into(),
|
||||
arguments: vec![json!({"title":"Exactly Once","content":"body"})],
|
||||
},
|
||||
);
|
||||
assert!(matches!(
|
||||
first.handle(create.clone()),
|
||||
ServerMessage::Response { .. }
|
||||
));
|
||||
assert_eq!(second.handle(create.clone()), first.handle(create));
|
||||
|
||||
let first_events = first.pending();
|
||||
let second_events = second.pending();
|
||||
assert_eq!(event_count(&first_events, &fixture.project_id), 1);
|
||||
assert_eq!(event_count(&second_events, &fixture.project_id), 1);
|
||||
assert!(strictly_ordered(&first_events));
|
||||
assert!(strictly_ordered(&second_events));
|
||||
|
||||
let db = fixture.host.database().unwrap();
|
||||
assert_eq!(
|
||||
bds_core::db::queries::post::list_posts_by_project(db.conn(), &fixture.project_id)
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simultaneous_replay_from_two_clients_executes_the_write_once() {
|
||||
let fixture = Fixture::new();
|
||||
let mut first = fixture.session();
|
||||
let mut second = fixture.session();
|
||||
for (index, session) in [&mut first, &mut second].into_iter().enumerate() {
|
||||
session.handle(request(
|
||||
&format!("parallel-open-{index}"),
|
||||
Command::OpenProject {
|
||||
project_id: fixture.project_id.clone(),
|
||||
},
|
||||
));
|
||||
}
|
||||
let barrier = Arc::new(std::sync::Barrier::new(2));
|
||||
let command = request(
|
||||
"same-concurrent-id",
|
||||
Command::Call {
|
||||
namespace: "posts".into(),
|
||||
method: "create".into(),
|
||||
arguments: vec![json!({"title":"Concurrent","content":"body"})],
|
||||
},
|
||||
);
|
||||
let first_barrier = Arc::clone(&barrier);
|
||||
let first_command = command.clone();
|
||||
let first = std::thread::spawn(move || {
|
||||
first_barrier.wait();
|
||||
first.handle(first_command)
|
||||
});
|
||||
let second = std::thread::spawn(move || {
|
||||
barrier.wait();
|
||||
second.handle(command)
|
||||
});
|
||||
assert_eq!(first.join().unwrap(), second.join().unwrap());
|
||||
let db = fixture.host.database().unwrap();
|
||||
assert_eq!(
|
||||
bds_core::db::queries::post::list_posts_by_project(db.conn(), &fixture.project_id)
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_progress_is_shared_without_repeating_unchanged_snapshots() {
|
||||
let fixture = Fixture::new();
|
||||
let mut session = fixture.session();
|
||||
let _ = session.pending();
|
||||
let task = fixture.host.tasks().submit("Generate site");
|
||||
fixture
|
||||
.host
|
||||
.tasks()
|
||||
.report_progress(task, Some(0.5), Some("Writing".into()));
|
||||
let update = session.pending();
|
||||
assert!(matches!(
|
||||
update.as_slice(),
|
||||
[ServerMessage::Tasks { tasks, .. }] if tasks[0].progress == Some(0.5)
|
||||
));
|
||||
assert!(session.pending().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_sync_watcher_republishes_external_cli_notifications() {
|
||||
let fixture = Fixture::new();
|
||||
let mut session = fixture.session();
|
||||
session.handle(request(
|
||||
"open-for-sync",
|
||||
Command::OpenProject {
|
||||
project_id: fixture.project_id.clone(),
|
||||
},
|
||||
));
|
||||
let _ = session.pending();
|
||||
|
||||
let db = fixture.host.database().unwrap();
|
||||
bds_core::engine::cli_sync::record_cli_event(
|
||||
db.conn(),
|
||||
&bds_core::model::DomainEvent::EntityChanged {
|
||||
project_id: fixture.project_id.clone(),
|
||||
entity: bds_core::model::DomainEntity::Post,
|
||||
entity_id: "external-post".into(),
|
||||
action: bds_core::model::NotificationAction::Created,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||
let mut updates = Vec::new();
|
||||
while std::time::Instant::now() < deadline {
|
||||
updates.extend(session.pending());
|
||||
if event_count(&updates, &fixture.project_id) == 1 {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
|
||||
assert_eq!(event_count(&updates, &fixture.project_id), 1);
|
||||
let notifications =
|
||||
bds_core::db::queries::db_notification::list_notifications(db.conn()).unwrap();
|
||||
assert_eq!(notifications.len(), 1);
|
||||
assert!(notifications[0].seen_at.is_some());
|
||||
}
|
||||
|
||||
fn event_count(messages: &[ServerMessage], project_id: &str) -> usize {
|
||||
messages
|
||||
.iter()
|
||||
.filter(|message| {
|
||||
matches!(
|
||||
message,
|
||||
ServerMessage::Event { event, .. }
|
||||
if event.project_id() == Some(project_id)
|
||||
)
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
fn strictly_ordered(messages: &[ServerMessage]) -> bool {
|
||||
let sequences = messages
|
||||
.iter()
|
||||
.filter_map(|message| match message {
|
||||
ServerMessage::Event { sequence, .. } | ServerMessage::Tasks { sequence, .. } => {
|
||||
Some(*sequence)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
sequences.windows(2).all(|pair| pair[0] < pair[1])
|
||||
}
|
||||
}
|
||||
63
crates/bds-server/src/lib.rs
Normal file
63
crates/bds-server/src/lib.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
pub mod auth;
|
||||
pub mod boot;
|
||||
pub mod client;
|
||||
pub mod host;
|
||||
pub mod protocol;
|
||||
pub mod transport;
|
||||
|
||||
pub use client::{DesktopClient, RemoteTarget};
|
||||
pub use transport::{ServerConfig, ServerRuntime};
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use boot::BootMode;
|
||||
|
||||
/// Run the headless host until Ctrl+C, optionally attaching the launching
|
||||
/// terminal in `tui` mode. This path never links or initializes Iced.
|
||||
pub fn run_headless(mode: BootMode, config: ServerConfig) -> Result<()> {
|
||||
if mode == BootMode::Desktop {
|
||||
bail!("desktop mode must be started by bds-ui");
|
||||
}
|
||||
let database_path = config.database_path.clone();
|
||||
let runtime = ServerRuntime::start(config)?;
|
||||
let locale = bds_core::db::Database::open(&database_path)
|
||||
.ok()
|
||||
.and_then(|database| {
|
||||
bds_core::engine::settings::ui_language(database.conn())
|
||||
.ok()
|
||||
.flatten()
|
||||
})
|
||||
.map(|language| bds_core::i18n::normalize_language(&language))
|
||||
.unwrap_or_else(bds_core::i18n::detect_os_locale);
|
||||
eprintln!(
|
||||
"{}",
|
||||
bds_core::i18n::translate_with(
|
||||
locale,
|
||||
"remoteTerminal.serverListening",
|
||||
&[("address", &runtime.address().to_string())],
|
||||
)
|
||||
);
|
||||
eprintln!(
|
||||
"{}",
|
||||
bds_core::i18n::translate_with(
|
||||
locale,
|
||||
"remoteTerminal.authorizedKeys",
|
||||
&[(
|
||||
"path",
|
||||
&runtime
|
||||
.key_material()
|
||||
.authorized_keys_path
|
||||
.display()
|
||||
.to_string(),
|
||||
)],
|
||||
)
|
||||
);
|
||||
if mode == BootMode::Tui {
|
||||
host::run_local_terminal(runtime.application_host())?;
|
||||
} else {
|
||||
let tokio = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
tokio.block_on(tokio::signal::ctrl_c())?;
|
||||
}
|
||||
runtime.stop()
|
||||
}
|
||||
41
crates/bds-server/src/main.rs
Normal file
41
crates/bds-server/src/main.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use std::net::IpAddr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use bds_server::boot::BootMode;
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "bds-server",
|
||||
about = "Headless RuDS engine host over authenticated SSH"
|
||||
)]
|
||||
struct Args {
|
||||
/// SSH listen address. Defaults to loopback; external access must be explicit.
|
||||
#[arg(long)]
|
||||
bind: Option<IpAddr>,
|
||||
/// SSH listen port.
|
||||
#[arg(long)]
|
||||
port: Option<u16>,
|
||||
/// Application database path.
|
||||
#[arg(long)]
|
||||
database: Option<PathBuf>,
|
||||
/// Private application data directory containing SSH key material.
|
||||
#[arg(long)]
|
||||
data_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let args = Args::parse();
|
||||
let data_root = args
|
||||
.data_dir
|
||||
.unwrap_or_else(bds_core::util::application_data_dir);
|
||||
let database_path = args.database.unwrap_or_else(|| data_root.join("bds.db"));
|
||||
let mut config = bds_server::ServerConfig::from_environment(database_path, data_root)?;
|
||||
if let Some(bind) = args.bind {
|
||||
config.bind = bind;
|
||||
}
|
||||
if let Some(port) = args.port {
|
||||
config.port = port;
|
||||
}
|
||||
bds_server::run_headless(BootMode::Server, config)
|
||||
}
|
||||
63
crates/bds-server/src/protocol.rs
Normal file
63
crates/bds-server/src/protocol.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use bds_core::model::DomainEvent;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
pub const SUBSYSTEM: &str = "ruds";
|
||||
pub const PROTOCOL_VERSION: u16 = 1;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Request {
|
||||
pub id: String,
|
||||
#[serde(flatten)]
|
||||
pub command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "command", rename_all = "snake_case")]
|
||||
pub enum Command {
|
||||
Hello {
|
||||
protocol_version: u16,
|
||||
},
|
||||
ListProjects,
|
||||
OpenProject {
|
||||
project_id: String,
|
||||
},
|
||||
Call {
|
||||
namespace: String,
|
||||
method: String,
|
||||
#[serde(default)]
|
||||
arguments: Vec<Value>,
|
||||
},
|
||||
Ping,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ServerMessage {
|
||||
Response {
|
||||
id: String,
|
||||
result: Value,
|
||||
},
|
||||
Error {
|
||||
id: String,
|
||||
code: String,
|
||||
message: String,
|
||||
},
|
||||
Event {
|
||||
sequence: u64,
|
||||
event: DomainEvent,
|
||||
},
|
||||
Tasks {
|
||||
sequence: u64,
|
||||
tasks: Vec<RemoteTask>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RemoteTask {
|
||||
pub id: u64,
|
||||
pub label: String,
|
||||
pub status: String,
|
||||
pub progress: Option<f32>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
523
crates/bds-server/src/transport.rs
Normal file
523
crates/bds-server/src/transport.rs
Normal file
@@ -0,0 +1,523 @@
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use bds_core::engine::mcp::McpHttpServer;
|
||||
use russh::keys::PublicKey;
|
||||
use russh::server::{Auth, ChannelOpenHandle, Handler, Msg, Server, Session};
|
||||
use russh::{Channel, ChannelId};
|
||||
use tokio::io::{AsyncBufReadExt as _, AsyncWrite, AsyncWriteExt as _, BufReader};
|
||||
|
||||
use crate::auth::KeyMaterial;
|
||||
use crate::host::{ApplicationHost, ApplicationSession};
|
||||
use crate::protocol::{Command, PROTOCOL_VERSION, Request, SUBSYSTEM, ServerMessage};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerConfig {
|
||||
pub database_path: PathBuf,
|
||||
pub data_root: PathBuf,
|
||||
pub bind: IpAddr,
|
||||
pub port: u16,
|
||||
pub mcp_port: u16,
|
||||
}
|
||||
|
||||
impl ServerConfig {
|
||||
pub fn local(database_path: PathBuf, data_root: PathBuf) -> Self {
|
||||
Self {
|
||||
database_path,
|
||||
data_root,
|
||||
bind: IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
port: 2222,
|
||||
mcp_port: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_environment(database_path: PathBuf, data_root: PathBuf) -> Result<Self> {
|
||||
let mut config = Self::local(database_path, data_root);
|
||||
if let Some(bind) = std::env::var_os("BDS_SSH_BIND") {
|
||||
config.bind = bind
|
||||
.to_string_lossy()
|
||||
.parse()
|
||||
.context("BDS_SSH_BIND must be an IP address")?;
|
||||
}
|
||||
if let Some(port) = std::env::var_os("BDS_SSH_PORT") {
|
||||
config.port = port
|
||||
.to_string_lossy()
|
||||
.parse()
|
||||
.context("BDS_SSH_PORT must be a TCP port")?;
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ServerRuntime {
|
||||
address: SocketAddr,
|
||||
key_material: KeyMaterial,
|
||||
host: ApplicationHost,
|
||||
shutdown: Option<tokio::sync::oneshot::Sender<()>>,
|
||||
thread: Option<JoinHandle<Result<()>>>,
|
||||
_mcp: McpHttpServer,
|
||||
}
|
||||
|
||||
impl ServerRuntime {
|
||||
pub fn start(config: ServerConfig) -> Result<Self> {
|
||||
let key_material = KeyMaterial::ensure(&config.data_root)?;
|
||||
let host = ApplicationHost::start(config.database_path.clone(), config.data_root.clone())?;
|
||||
let server_host = host.clone();
|
||||
let mcp = McpHttpServer::start(config.database_path, config.mcp_port)?;
|
||||
let forward_address = mcp.address();
|
||||
let listener =
|
||||
std::net::TcpListener::bind((config.bind, config.port)).with_context(|| {
|
||||
format!(
|
||||
"could not bind the RuDS SSH server to {}:{}",
|
||||
config.bind, config.port
|
||||
)
|
||||
})?;
|
||||
listener.set_nonblocking(true)?;
|
||||
let address = listener.local_addr()?;
|
||||
let host_key = key_material.host_key()?;
|
||||
let auth = key_material.clone();
|
||||
let (shutdown, shutdown_rx) = tokio::sync::oneshot::channel();
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("bds-ssh-server".into())
|
||||
.spawn(move || -> Result<()> {
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()?;
|
||||
runtime.block_on(async move {
|
||||
let listener = tokio::net::TcpListener::from_std(listener)?;
|
||||
let ssh_config = Arc::new(russh::server::Config {
|
||||
inactivity_timeout: Some(Duration::from_secs(60 * 60)),
|
||||
auth_rejection_time: Duration::from_millis(500),
|
||||
auth_rejection_time_initial: Some(Duration::ZERO),
|
||||
keys: vec![host_key],
|
||||
nodelay: true,
|
||||
..Default::default()
|
||||
});
|
||||
let mut factory = ServerFactory {
|
||||
auth,
|
||||
host: server_host,
|
||||
forward_address,
|
||||
};
|
||||
let running = factory.run_on_socket(ssh_config, &listener);
|
||||
let handle = running.handle();
|
||||
tokio::spawn(async move {
|
||||
let _ = shutdown_rx.await;
|
||||
handle.shutdown("RuDS server is shutting down".into());
|
||||
});
|
||||
running.await.map_err(anyhow::Error::from)
|
||||
})
|
||||
})?;
|
||||
Ok(Self {
|
||||
address,
|
||||
key_material,
|
||||
host,
|
||||
shutdown: Some(shutdown),
|
||||
thread: Some(thread),
|
||||
_mcp: mcp,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn address(&self) -> SocketAddr {
|
||||
self.address
|
||||
}
|
||||
|
||||
pub fn key_material(&self) -> &KeyMaterial {
|
||||
&self.key_material
|
||||
}
|
||||
|
||||
pub fn application_host(&self) -> ApplicationHost {
|
||||
self.host.clone()
|
||||
}
|
||||
|
||||
pub fn stop(mut self) -> Result<()> {
|
||||
self.shutdown.take();
|
||||
if let Some(thread) = self.thread.take() {
|
||||
thread
|
||||
.join()
|
||||
.map_err(|_| anyhow!("RuDS SSH server thread panicked"))??;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ServerRuntime {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown.take();
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ServerFactory {
|
||||
auth: KeyMaterial,
|
||||
host: ApplicationHost,
|
||||
forward_address: SocketAddr,
|
||||
}
|
||||
|
||||
impl Server for ServerFactory {
|
||||
type Handler = ConnectionHandler;
|
||||
|
||||
fn new_client(&mut self, _peer_addr: Option<SocketAddr>) -> Self::Handler {
|
||||
ConnectionHandler {
|
||||
auth: self.auth.clone(),
|
||||
host: self.host.clone(),
|
||||
forward_address: self.forward_address,
|
||||
channels: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConnectionHandler {
|
||||
auth: KeyMaterial,
|
||||
host: ApplicationHost,
|
||||
forward_address: SocketAddr,
|
||||
channels: HashMap<ChannelId, Channel<Msg>>,
|
||||
}
|
||||
|
||||
impl Handler for ConnectionHandler {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
async fn auth_publickey(&mut self, _user: &str, key: &PublicKey) -> Result<Auth, Self::Error> {
|
||||
Ok(if self.auth.authorizes(key)? {
|
||||
Auth::Accept
|
||||
} else {
|
||||
Auth::reject()
|
||||
})
|
||||
}
|
||||
|
||||
async fn channel_open_session(
|
||||
&mut self,
|
||||
channel: Channel<Msg>,
|
||||
reply: ChannelOpenHandle,
|
||||
_session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
self.channels.insert(channel.id(), channel);
|
||||
reply.accept().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn pty_request(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
_term: &str,
|
||||
_col_width: u32,
|
||||
_row_height: u32,
|
||||
_pix_width: u32,
|
||||
_pix_height: u32,
|
||||
_modes: &[(russh::Pty, u32)],
|
||||
session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
session.channel_success(channel)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn subsystem_request(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
name: &str,
|
||||
session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
if name != SUBSYSTEM {
|
||||
session.channel_failure(channel)?;
|
||||
return Ok(());
|
||||
}
|
||||
let Some(channel) = self.channels.remove(&channel) else {
|
||||
session.channel_failure(channel)?;
|
||||
return Ok(());
|
||||
};
|
||||
let application = self.host.session()?;
|
||||
session.channel_success(channel.id())?;
|
||||
tokio::spawn(async move {
|
||||
let _ = run_protocol(channel, application).await;
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn shell_request(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
let Some(channel) = self.channels.remove(&channel) else {
|
||||
session.channel_failure(channel)?;
|
||||
return Ok(());
|
||||
};
|
||||
let application = self.host.session()?;
|
||||
session.channel_success(channel.id())?;
|
||||
tokio::spawn(async move {
|
||||
let _ = run_terminal_session(channel, application).await;
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn channel_open_direct_tcpip(
|
||||
&mut self,
|
||||
channel: Channel<Msg>,
|
||||
host_to_connect: &str,
|
||||
port_to_connect: u32,
|
||||
_originator_address: &str,
|
||||
_originator_port: u32,
|
||||
reply: ChannelOpenHandle,
|
||||
_session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
let permitted_host = matches!(host_to_connect, "127.0.0.1" | "localhost" | "::1");
|
||||
if !permitted_host || port_to_connect != u32::from(self.forward_address.port()) {
|
||||
return Ok(());
|
||||
}
|
||||
let Ok(mut target) = tokio::net::TcpStream::connect(self.forward_address).await else {
|
||||
return Ok(());
|
||||
};
|
||||
reply.accept().await;
|
||||
tokio::spawn(async move {
|
||||
let mut stream = channel.into_stream();
|
||||
let _ = tokio::io::copy_bidirectional(&mut stream, &mut target).await;
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_protocol(channel: Channel<Msg>, mut application: ApplicationSession) -> Result<()> {
|
||||
let (read, mut write) = tokio::io::split(channel.into_stream());
|
||||
let mut lines = BufReader::new(read).lines();
|
||||
let mut interval = tokio::time::interval(Duration::from_millis(100));
|
||||
loop {
|
||||
tokio::select! {
|
||||
line = lines.next_line() => {
|
||||
let Some(line) = line? else { break };
|
||||
let response = if line.len() > 1024 * 1024 {
|
||||
ServerMessage::Error { id: String::new(), code: "request_too_large".into(), message: "remote request exceeds 1 MiB".into() }
|
||||
} else {
|
||||
match serde_json::from_str::<Request>(&line) {
|
||||
Ok(request) => application.handle(request),
|
||||
Err(error) => ServerMessage::Error { id: String::new(), code: "invalid_request".into(), message: error.to_string() },
|
||||
}
|
||||
};
|
||||
write_message(&mut write, &response).await?;
|
||||
}
|
||||
_ = interval.tick() => {
|
||||
for message in application.pending() {
|
||||
write_message(&mut write, &message).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_message(
|
||||
write: &mut (impl AsyncWrite + Unpin),
|
||||
message: &ServerMessage,
|
||||
) -> Result<()> {
|
||||
let mut encoded = serde_json::to_vec(message)?;
|
||||
encoded.push(b'\n');
|
||||
write.write_all(&encoded).await?;
|
||||
write.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_terminal_session(
|
||||
mut channel: Channel<Msg>,
|
||||
mut application: ApplicationSession,
|
||||
) -> Result<()> {
|
||||
let locale = application.locale().to_owned();
|
||||
let hello = application.handle(Request {
|
||||
id: "terminal-hello".into(),
|
||||
command: Command::Hello {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
},
|
||||
});
|
||||
if matches!(hello, ServerMessage::Error { .. }) {
|
||||
return Err(anyhow!("terminal protocol negotiation failed"));
|
||||
}
|
||||
let projects = application.handle(Request {
|
||||
id: "terminal-projects".into(),
|
||||
command: Command::ListProjects,
|
||||
});
|
||||
let project_names = match projects {
|
||||
ServerMessage::Response { result, .. } => result
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|project| project.get("name").and_then(|name| name.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\r\n • "),
|
||||
_ => String::new(),
|
||||
};
|
||||
let banner = terminal_banner(&locale, &project_names);
|
||||
channel.data(banner.as_bytes()).await?;
|
||||
let mut interval = tokio::time::interval(Duration::from_millis(250));
|
||||
loop {
|
||||
tokio::select! {
|
||||
message = channel.wait() => match message {
|
||||
Some(russh::ChannelMsg::Data { data }) if data.iter().any(|byte| matches!(byte, b'q' | 3)) => {
|
||||
channel.close().await?;
|
||||
break;
|
||||
}
|
||||
None | Some(russh::ChannelMsg::Close) => break,
|
||||
_ => {}
|
||||
},
|
||||
_ = interval.tick() => {
|
||||
let updates = application.pending();
|
||||
if !updates.is_empty() {
|
||||
let status = format!("\r\n[{} update{}]\r\n", updates.len(), if updates.len() == 1 { "" } else { "s" });
|
||||
channel.data(status.as_bytes()).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn terminal_banner(locale: &str, projects: &str) -> String {
|
||||
let locale = bds_core::i18n::normalize_language(locale);
|
||||
let title = bds_core::i18n::translate(locale, "remoteTerminal.serverTitle");
|
||||
let available = bds_core::i18n::translate(locale, "remoteTerminal.availableProjects");
|
||||
let quit = bds_core::i18n::translate(locale, "remoteTerminal.quit");
|
||||
let projects = if projects.is_empty() { "—" } else { projects };
|
||||
format!("\x1b[2J\x1b[H{title}\r\n\r\n{available}:\r\n • {projects}\r\n\r\n{quit}\r\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::auth::ClientKeyMaterial;
|
||||
use crate::client::{DesktopClient, RemoteTarget};
|
||||
use bds_core::db::Database;
|
||||
use std::fs;
|
||||
use std::thread;
|
||||
|
||||
#[test]
|
||||
fn defaults_to_loopback_and_external_binding_requires_an_explicit_value() {
|
||||
let config = ServerConfig::local("db".into(), "data".into());
|
||||
assert_eq!(config.bind, IpAddr::V4(Ipv4Addr::LOCALHOST));
|
||||
assert_eq!(config.port, 2222);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_banner_uses_the_server_locale_and_only_terminal_control_bytes() {
|
||||
let banner = terminal_banner("de", "Blog");
|
||||
assert!(banner.starts_with("\x1b[2J\x1b[H"));
|
||||
assert!(banner.contains("RuDS-Serversitzung"));
|
||||
assert!(banner.contains("Blog"));
|
||||
assert!(!banner.contains('{'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_ssh_authentication_revocation_reconnect_events_and_shutdown() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let server_data = root.path().join("server");
|
||||
let client_data = root.path().join("client");
|
||||
let unknown_data = root.path().join("unknown");
|
||||
fs::create_dir_all(&server_data).unwrap();
|
||||
let database_path = server_data.join("bds.db");
|
||||
let db = Database::open(&database_path).unwrap();
|
||||
db.migrate().unwrap();
|
||||
let project_dir = root.path().join("blog");
|
||||
let project = bds_core::engine::project::create_project(
|
||||
db.conn(),
|
||||
"Remote Blog",
|
||||
Some(project_dir.to_str().unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
bds_core::engine::settings::set(
|
||||
db.conn(),
|
||||
bds_core::engine::settings::UI_LANGUAGE_KEY,
|
||||
"fr",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let runtime = ServerRuntime::start(ServerConfig {
|
||||
database_path,
|
||||
data_root: server_data.clone(),
|
||||
bind: IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
port: 0,
|
||||
mcp_port: 0,
|
||||
})
|
||||
.unwrap();
|
||||
let target = RemoteTarget {
|
||||
user: "author".into(),
|
||||
host: runtime.address().ip().to_string(),
|
||||
port: runtime.address().port(),
|
||||
};
|
||||
|
||||
let unknown = match DesktopClient::connect(target.clone(), &unknown_data) {
|
||||
Ok(_) => panic!("unknown key authenticated"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(
|
||||
unknown
|
||||
.to_string()
|
||||
.contains("public-key authentication failed")
|
||||
);
|
||||
|
||||
let identity = ClientKeyMaterial::ensure(&client_data).unwrap();
|
||||
let public_key = fs::read_to_string(&identity.public_key_path).unwrap();
|
||||
fs::write(&runtime.key_material().authorized_keys_path, &public_key).unwrap();
|
||||
|
||||
let first = DesktopClient::connect(target.clone(), &client_data).unwrap();
|
||||
let second = DesktopClient::connect(target.clone(), &client_data).unwrap();
|
||||
assert_eq!(first.server_locale(), "fr");
|
||||
assert_eq!(second.server_locale(), "fr");
|
||||
assert_eq!(first.list_projects().unwrap()[0].name, "Remote Blog");
|
||||
first.open_project(&project.id).unwrap();
|
||||
second.open_project(&project.id).unwrap();
|
||||
let _ = first.drain_events();
|
||||
let _ = second.drain_events();
|
||||
first
|
||||
.call(
|
||||
"posts",
|
||||
"create",
|
||||
vec![serde_json::json!({"title":"Over SSH","content":"body"})],
|
||||
)
|
||||
.unwrap();
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
assert_eq!(domain_event_count(&first.drain_events(), &project.id), 1);
|
||||
assert_eq!(domain_event_count(&second.drain_events(), &project.id), 1);
|
||||
assert_eq!(
|
||||
bds_core::db::queries::post::list_posts_by_project(db.conn(), &project.id)
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
|
||||
// Revocation affects new authentication attempts immediately without
|
||||
// corrupting an already authenticated session.
|
||||
fs::write(&runtime.key_material().authorized_keys_path, "").unwrap();
|
||||
first.ping().unwrap();
|
||||
second.disconnect().unwrap();
|
||||
let revoked = match DesktopClient::connect(target.clone(), &client_data) {
|
||||
Ok(_) => panic!("revoked key authenticated"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(
|
||||
revoked
|
||||
.to_string()
|
||||
.contains("public-key authentication failed")
|
||||
);
|
||||
|
||||
fs::write(&runtime.key_material().authorized_keys_path, public_key).unwrap();
|
||||
let reconnected = DesktopClient::connect(target, &client_data).unwrap();
|
||||
reconnected.ping().unwrap();
|
||||
reconnected.disconnect().unwrap();
|
||||
|
||||
runtime.stop().unwrap();
|
||||
assert!(first.ping().is_err());
|
||||
first.disconnect().unwrap();
|
||||
}
|
||||
|
||||
fn domain_event_count(messages: &[ServerMessage], project_id: &str) -> usize {
|
||||
messages
|
||||
.iter()
|
||||
.filter(|message| {
|
||||
matches!(message, ServerMessage::Event { event, .. } if event.project_id() == Some(project_id))
|
||||
})
|
||||
.count()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user