Implement the headless SSH server

This commit is contained in:
2026-07-19 20:35:02 +02:00
parent 92a942d52f
commit 45cb0cd502
26 changed files with 4226 additions and 55 deletions

View File

@@ -0,0 +1,19 @@
[package]
name = "bds-server"
edition.workspace = true
version.workspace = true
license.workspace = true
[dependencies]
bds-core = { workspace = true }
anyhow = { workspace = true }
clap = { workspace = true }
dirs = { workspace = true }
russh = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
uuid = { workspace = true }
[dev-dependencies]
tempfile = "3"

View 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
);
}
}
}

View 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
);
}
}

View 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());
}
}

View 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])
}
}

View 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()
}

View 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)
}

View 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>,
}

View 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()
}
}