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

1230
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@ members = [
"crates/bds-ui",
"crates/bds-cli",
"crates/bds-mcp",
"crates/bds-server",
]
[workspace.package]
@@ -55,6 +56,7 @@ url = "2.5"
fastembed = { version = "5.17.3", default-features = false, features = ["hf-hub-rustls-tls", "ort-download-binaries-rustls-tls"] }
usearch = "2.26"
ort = { version = "=2.0.0-rc.12", default-features = false, features = ["std", "api-24", "coreml", "directml"] }
russh = "0.62.2"
# UI framework
iced = { version = "0.13", features = ["wgpu", "advanced", "canvas", "image", "svg", "tokio", "markdown"] }
@@ -72,3 +74,4 @@ rfd = "0.15"
bds-core = { path = "crates/bds-core" }
bds-editor = { path = "crates/bds-editor" }
bds-mcp = { path = "crates/bds-mcp" }
bds-server = { path = "crates/bds-server" }

View File

@@ -18,6 +18,7 @@ The project is under active development. Core blogging workflows are broadly ava
- Project-scoped typed domain events synchronize desktop views with shared-engine and future CLI mutations; persisted CLI notifications are consumed once, and the selected UI language is shared through settings.
- Headless `bds-cli` automation for rebuild/repair/render, publishing and Git sync, post/media/gallery creation, shared settings/projects, utility Lua tasks, JSON I/O, airplane-mode AI routing, and guarded launcher installation from Settings → Data or `bds-cli install`.
- Local MCP automation over stdio or a localhost-only stateless HTTP endpoint, with project resources, read/search/count tools, inert write proposals, explicit desktop approval, and opt-in Claude Code/Copilot configuration.
- Headless `bds-server` hosting the shared application engines over a loopback-by-default, public-key-only SSH service, with restrictive private key material, live authorization updates, terminal-session transport, CLI-change synchronization, ordered domain/task events, and native desktop remote-project selection.
- Markdown/Liquid rendering with native macros, multilingual routes, feeds, sitemap, Pagefind, and incremental site generation through cancellable section task groups.
- Local preview in the app or system browser.
- Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations using online or local OpenAI-compatible endpoints with airplane-mode gating.
@@ -35,6 +36,7 @@ RuDS uses no JavaScript application runtime and loads no CSS or JavaScript from
- `crates/bds-ui` — desktop application and platform integration
- `crates/bds-cli` — headless automation CLI over the shared engines
- `crates/bds-mcp` — packaged stdio MCP transport over the shared MCP engine
- `crates/bds-server` — headless engine host, SSH transport, remote protocol, and desktop client
- `specs` — authoritative Allium behavior specifications
- `fixtures` — compatibility projects and generated-site fixtures
- `locales` — UI and native-menu translations
@@ -48,3 +50,7 @@ RuDS uses no JavaScript application runtime and loads no CSS or JavaScript from
- `../bDS2` — reference implementation when an Allium contract is ambiguous
Contributor workflow and project invariants are documented in [AGENTS.md](AGENTS.md).
## Headless server
Run `bds-server` for a dedicated headless process, or set `BDS_MODE=server` when launching `bds-ui`. The SSH listener defaults to `127.0.0.1:2222`; use `--bind`/`BDS_SSH_BIND` and `--port`/`BDS_SSH_PORT` to opt into another address. Startup prints the private `authorized_keys` path. The desktop creates its own private `id_ed25519.pub`; add that public key to the server file, then use **File → Connect to Server…** and select a remote project. Host keys are recorded on first connection and verified thereafter. `BDS_MODE=tui` starts the same headless host with a terminal session attached to the launching shell.

View File

@@ -29,6 +29,7 @@ Status in this document describes the current source code as of 2026-07-19. It d
| `bds-editor` | Reusable Ropey/Syntect/Cosmic Text editor widget |
| `bds-ui` | Iced application, native menus/dialogs, platform lifecycle, and embedded preview |
| `bds-cli` | Extension-only headless automation surface over the shared engines |
| `bds-server` | Extension-only headless engine host and authenticated SSH transport |
## Current Core Status

View File

@@ -89,14 +89,15 @@ Done:
- Project-scoped `bds.*` capabilities, managed task progress, and operator cancellation during transform execution.
- bDS2-compatible delivery behavior without adding unsupported deep-link actions.
### Headless server — Open
### Headless server — Complete
Open:
Done:
- Desktop/server/TUI boot-mode selection.
- Headless engine host and SSH transport.
- Private host-key and authorized-key management.
- Desktop connection flow for remote projects.
- Desktop/server/TUI boot selection occurs before desktop initialization; the dedicated `bds-server` binary never depends on or starts the native UI.
- The headless host reuses the application database, project registry, `CoreHost` service operations, task manager, MCP loopback endpoint, ordered domain-event bus, and CLI-notification watcher.
- A loopback-by-default Russh daemon generates and reuses a restrictive RSA host key, validates private directory/file modes, reloads `authorized_keys` for every authentication, and supports explicit external binding only.
- Versioned native remote-project sessions provide protocol negotiation, server locale, project selection, shared engine calls, replay-safe request IDs, ordered domain events, task snapshots, reconnect, concurrent clients, and graceful shutdown.
- The desktop uses a restrictive generated Ed25519 identity and TOFU `known_hosts`, with localized File-menu connect/project selection/open/failure/disconnect states. SSH shell channels host server-side terminal sessions and direct forwarding is restricted to the server-owned loopback endpoint.
### Terminal UI — Open

View File

@@ -88,6 +88,13 @@ impl CoreHost {
self
}
/// Attach the application-wide task service for remote and alternate UI
/// sessions that are not themselves running inside one managed task.
pub fn with_task_manager(mut self, manager: Arc<TaskManager>) -> Self {
self.task_manager = Some(manager);
self
}
pub fn with_offline_mode(mut self, offline_mode: bool) -> Self {
self.offline_mode = offline_mode;
self

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

View File

@@ -7,6 +7,7 @@ license.workspace = true
[dependencies]
bds-core = { workspace = true }
bds-editor = { workspace = true }
bds-server = { workspace = true }
iced = { workspace = true }
muda = { workspace = true }
rfd = { workspace = true }
@@ -38,11 +39,12 @@ winresource = "0.1"
product-name = "Blogging Desktop Server"
identifier = "de.rfc1437.ruds"
description = "A desktop application for writing and publishing static blogs."
before-packaging-command = "cargo build --release -p bds-ui -p bds-cli -p bds-mcp"
before-packaging-command = "cargo build --release -p bds-ui -p bds-cli -p bds-mcp -p bds-server"
binaries = [
{ path = "bds-ui", main = true },
{ path = "bds-cli" },
{ path = "bds-mcp" },
{ path = "bds-server" },
]
deep-link-protocols = [{ schemes = ["ruds"] }]
icons = [

View File

@@ -200,6 +200,13 @@ pub enum Message {
ShowModal(modal::ModalState),
DismissModal,
ConfirmModal(modal::ConfirmAction),
RemoteTargetChanged(String),
RemoteConnectRequested,
RemoteConnected(Result<(Arc<bds_server::DesktopClient>, Vec<Project>), String>),
RemoteProjectSelected(String),
RemoteOpenProjectRequested,
RemoteProjectOpened(Result<Project, String>),
RemoteDisconnectRequested,
FindQueryChanged(String),
ReplaceQueryChanged(String),
FindNext,
@@ -933,6 +940,11 @@ pub struct BdsApp {
// Modal
active_modal: Option<modal::ModalState>,
remote_client: Option<Arc<bds_server::DesktopClient>>,
remote_projects: Vec<Project>,
remote_project: Option<Project>,
remote_display_name: Option<String>,
remote_previous_locale: Option<UiLocale>,
// Local preview
preview_session: Option<PreviewSession>,
@@ -1048,6 +1060,7 @@ impl BdsApp {
registry.set_enabled(MenuAction::Find, false);
registry.set_enabled(MenuAction::Replace, false);
registry.set_enabled(MenuAction::OpenInBrowser, false);
registry.set_enabled(MenuAction::DisconnectServer, false);
let chat_conversations = db
.as_ref()
.and_then(|db| engine::chat::list_conversations(db.conn()).ok())
@@ -1105,6 +1118,11 @@ impl BdsApp {
toasts: Vec::new(),
active_modal: search_index_rebuild_required
.then_some(modal::ModalState::SearchIndexRepair),
remote_client: None,
remote_projects: Vec::new(),
remote_project: None,
remote_display_name: None,
remote_previous_locale: None,
preview_session: None,
mcp_server,
embedded_preview: None,
@@ -1187,6 +1205,11 @@ impl BdsApp {
theme_badge: String::from("pico"),
toasts: Vec::new(),
active_modal: None,
remote_client: None,
remote_projects: Vec::new(),
remote_project: None,
remote_display_name: None,
remote_previous_locale: None,
preview_session: None,
mcp_server: None,
embedded_preview: None,
@@ -2694,6 +2717,212 @@ impl BdsApp {
}
}
}
Message::RemoteTargetChanged(value) => {
if let Some(modal::ModalState::RemoteConnection { target, error, .. }) =
self.active_modal.as_mut()
{
*target = value;
*error = None;
}
Task::none()
}
Message::RemoteConnectRequested => {
let target = match self.active_modal.as_mut() {
Some(modal::ModalState::RemoteConnection {
target,
connecting,
error,
..
}) => {
*connecting = true;
*error = None;
target.clone()
}
_ => return Task::none(),
};
let parsed = match bds_server::RemoteTarget::parse(&target) {
Ok(target) => target,
Err(error) => {
if let Some(modal::ModalState::RemoteConnection {
connecting,
error: shown,
..
}) = self.active_modal.as_mut()
{
*connecting = false;
let _ = error;
*shown = Some(t(self.ui_locale, "remoteConnection.invalidTarget"));
}
return Task::none();
}
};
let data_root = bds_core::util::application_data_dir();
Task::perform(
async move {
tokio::task::spawn_blocking(move || {
let client = Arc::new(
bds_server::DesktopClient::connect(parsed, &data_root)
.map_err(|error| error.to_string())?,
);
let projects =
client.list_projects().map_err(|error| error.to_string())?;
Ok((client, projects))
})
.await
.unwrap_or_else(|error| {
Err(format!("remote connection task failed: {error}"))
})
},
Message::RemoteConnected,
)
}
Message::RemoteConnected(result) => {
match result {
Ok((client, projects)) => {
let server_locale =
bds_core::i18n::normalize_language(client.server_locale());
self.remote_projects.clone_from(&projects);
self.remote_client = Some(client);
if let Some(modal::ModalState::RemoteConnection {
connecting,
connected,
error,
projects: shown_projects,
selected_project_id,
..
}) = self.active_modal.as_mut()
{
*connecting = false;
*connected = true;
*error = None;
shown_projects.clone_from(&projects);
*selected_project_id =
projects.first().map(|project| project.id.clone());
}
self.menu_registry
.set_enabled(MenuAction::DisconnectServer, true);
if self.remote_previous_locale.is_none() {
self.remote_previous_locale = Some(self.ui_locale);
}
self.apply_ui_locale(server_locale);
}
Err(connection_error) => {
let remains_connected = self.remote_client.is_some();
if let Some(modal::ModalState::RemoteConnection {
connecting,
connected,
error,
..
}) = self.active_modal.as_mut()
{
*connecting = false;
*connected = remains_connected;
*error = Some(tw(
self.ui_locale,
"remoteConnection.failed",
&[("reason", &connection_error)],
));
}
}
}
Task::none()
}
Message::RemoteProjectSelected(project_id) => {
if let Some(modal::ModalState::RemoteConnection {
selected_project_id,
error,
..
}) = self.active_modal.as_mut()
{
*selected_project_id = Some(project_id);
*error = None;
}
Task::none()
}
Message::RemoteOpenProjectRequested => {
let project_id = match self.active_modal.as_mut() {
Some(modal::ModalState::RemoteConnection {
selected_project_id: Some(project_id),
connecting,
error,
..
}) => {
*connecting = true;
*error = None;
project_id.clone()
}
_ => return Task::none(),
};
let Some(client) = self.remote_client.clone() else {
return Task::done(Message::RemoteProjectOpened(Err(t(
self.ui_locale,
"remoteConnection.connectionLost",
))));
};
Task::perform(
async move {
tokio::task::spawn_blocking(move || {
client
.open_project(&project_id)
.map_err(|error| error.to_string())
})
.await
.unwrap_or_else(|error| Err(format!("remote project task failed: {error}")))
},
Message::RemoteProjectOpened,
)
}
Message::RemoteProjectOpened(result) => {
match result {
Ok(project) => {
let target = self
.remote_client
.as_ref()
.map(|client| client.target().label())
.unwrap_or_default();
self.remote_display_name = Some(format!("{}{target}", project.name));
self.remote_project = Some(project);
self.active_modal = None;
self.notify(
ToastLevel::Success,
&t(self.ui_locale, "remoteConnection.opened"),
);
}
Err(open_error) => {
if let Some(modal::ModalState::RemoteConnection {
connecting, error, ..
}) = self.active_modal.as_mut()
{
*connecting = false;
*error = Some(tw(
self.ui_locale,
"remoteConnection.openFailed",
&[("reason", &open_error)],
));
}
}
}
Task::none()
}
Message::RemoteDisconnectRequested => {
if let Some(client) = self.remote_client.take() {
client.close();
}
self.remote_projects.clear();
self.remote_project = None;
self.remote_display_name = None;
self.active_modal = None;
self.menu_registry
.set_enabled(MenuAction::DisconnectServer, false);
if let Some(locale) = self.remote_previous_locale.take() {
self.apply_ui_locale(locale);
}
self.notify(
ToastLevel::Info,
&t(self.ui_locale, "remoteConnection.disconnected"),
);
Task::none()
}
Message::FindQueryChanged(value) => {
if let Some(modal::ModalState::FindReplace { query, .. }) =
self.active_modal.as_mut()
@@ -3095,7 +3324,11 @@ impl BdsApp {
}
pub fn view(&self) -> Element<'_, Message> {
let active_name = self.active_project.as_ref().map(|p| p.name.as_str());
let active_name = self.remote_display_name.as_deref().or_else(|| {
self.active_project
.as_ref()
.map(|project| project.name.as_str())
});
let active_post_filter = match self.sidebar_view {
SidebarView::Pages => &self.page_filter,
_ => &self.post_filter,
@@ -3615,6 +3848,47 @@ impl BdsApp {
}
fn process_domain_events(&mut self) -> Task<Message> {
let remote_messages = self
.remote_client
.as_ref()
.map(|client| client.drain_events())
.unwrap_or_default();
for message in remote_messages {
match message {
bds_server::protocol::ServerMessage::Event { sequence, event } => {
self.add_output(&tw(
self.ui_locale,
"remoteConnection.eventReceived",
&[
("sequence", &sequence.to_string()),
("event", &format!("{event:?}")),
],
));
}
bds_server::protocol::ServerMessage::Tasks { tasks, .. } => {
self.add_output(&tw(
self.ui_locale,
"remoteConnection.taskUpdate",
&[("count", &tasks.len().to_string())],
));
}
bds_server::protocol::ServerMessage::Error { code, message, .. } => {
self.notify(ToastLevel::Error, &message);
if remote_error_closes_connection(&code) {
self.remote_client = None;
self.remote_project = None;
self.remote_projects.clear();
self.remote_display_name = None;
self.menu_registry
.set_enabled(MenuAction::DisconnectServer, false);
if let Some(locale) = self.remote_previous_locale.take() {
self.apply_ui_locale(locale);
}
}
}
bds_server::protocol::ServerMessage::Response { .. } => {}
}
}
if let Some(db) = &self.db {
let _ = engine::cli_sync::poll_notifications(db.conn());
}
@@ -3849,6 +4123,26 @@ impl BdsApp {
}
Task::none()
}
MenuAction::ConnectServer => {
let target = self
.remote_client
.as_ref()
.map(|client| client.target().label())
.unwrap_or_default();
self.active_modal = Some(modal::ModalState::RemoteConnection {
target,
connecting: false,
connected: self.remote_client.is_some(),
error: None,
projects: self.remote_projects.clone(),
selected_project_id: self
.remote_project
.as_ref()
.map(|project| project.id.clone()),
});
Task::none()
}
MenuAction::DisconnectServer => Task::done(Message::RemoteDisconnectRequested),
// Edit
MenuAction::Find => {
self.active_modal = Some(modal::ModalState::FindReplace {
@@ -5279,6 +5573,8 @@ impl BdsApp {
),
);
}
self.menu_registry
.set_enabled(MenuAction::DisconnectServer, self.remote_client.is_some());
}
// ── Editor save/publish helpers ──
@@ -8652,16 +8948,21 @@ fn language_label(locale: UiLocale, code: &str) -> String {
}
}
fn remote_error_closes_connection(code: &str) -> bool {
code == "connection_lost"
}
#[cfg(test)]
mod tests {
use super::{
BdsApp, Message, POST_AUTO_SAVE_DELAY_MS, PersistedMediaState, PersistedPostState,
PostStatus, SettingsMsg, month_abbreviation, persist_media_editor_state_impl,
persist_post_editor_preview_state_impl, persist_post_editor_state_impl,
save_editor_settings_state_impl, save_script_editor_state_impl,
save_template_editor_state_impl,
remote_error_closes_connection, save_editor_settings_state_impl,
save_script_editor_state_impl, save_template_editor_state_impl,
};
use crate::i18n::t;
use crate::platform::menu::MenuAction;
use crate::state::ToastLevel;
use crate::state::sidebar_filter::{MediaFilter, PostFilter};
use crate::state::tabs::{Tab, TabType};
@@ -8735,6 +9036,49 @@ mod tests {
(db, project, tempdir)
}
#[test]
fn remote_connection_menu_opens_localized_selection_and_failure_states() {
let (db, project, temp) = setup();
let mut app = BdsApp::new_for_tests(db, project, temp.path().to_path_buf());
let _ = app.dispatch_menu_action(MenuAction::ConnectServer);
assert!(matches!(
app.active_modal,
Some(modal::ModalState::RemoteConnection {
connected: false,
ref projects,
..
}) if projects.is_empty()
));
let _ = app.update(Message::RemoteTargetChanged("not-a-target".into()));
let _ = app.update(Message::RemoteConnectRequested);
assert!(matches!(
app.active_modal,
Some(modal::ModalState::RemoteConnection {
connecting: false,
error: Some(ref error),
..
}) if error == &t(UiLocale::En, "remoteConnection.invalidTarget")
));
let _ = app.update(Message::RemoteConnected(Err("refused".into())));
assert!(matches!(
app.active_modal,
Some(modal::ModalState::RemoteConnection {
connected: false,
error: Some(ref error),
..
}) if error.contains("refused") && error != "refused"
));
}
#[test]
fn only_transport_loss_closes_an_open_remote_session() {
assert!(remote_error_closes_connection("connection_lost"));
assert!(!remote_error_closes_connection("sync_watcher_error"));
assert!(!remote_error_closes_connection("engine_error"));
}
#[test]
fn documentation_external_links_require_confirmation_and_api_help_opens_real_tab() {
let (db, project, temp) = setup();

View File

@@ -1,9 +1,28 @@
#![cfg_attr(target_os = "windows", windows_subsystem = "windows")]
use bds_server::boot::{BootMode, Platform};
use bds_ui::BdsApp;
use bds_ui::components::inputs;
fn main() -> iced::Result {
fn main() -> anyhow::Result<()> {
let resolved = BootMode::resolve(std::env::var("BDS_MODE").ok().as_deref());
let env = std::env::vars().collect();
let mode = resolved.effective(current_platform(), &env);
if mode != BootMode::Desktop {
if mode != resolved {
let locale = bds_core::i18n::detect_os_locale();
eprintln!(
"{}",
bds_core::i18n::translate(locale, "remoteTerminal.headlessFallback")
);
}
let config = bds_server::ServerConfig::from_environment(
bds_core::util::application_database_path(),
bds_core::util::application_data_dir(),
)?;
return bds_server::run_headless(mode, config);
}
let icon =
iced::window::icon::from_file_data(include_bytes!("../assets/app-icons/bds.png"), None)
.expect("bundled application icon must be valid");
@@ -16,5 +35,17 @@ fn main() -> iced::Result {
icon: Some(icon),
..Default::default()
})
.run_with(BdsApp::new)
.run_with(BdsApp::new)?;
Ok(())
}
fn current_platform() -> Platform {
#[cfg(target_os = "macos")]
return Platform::MacOs;
#[cfg(windows)]
return Platform::Windows;
#[cfg(all(unix, not(target_os = "macos")))]
return Platform::Unix;
#[allow(unreachable_code)]
Platform::Windows
}

View File

@@ -20,6 +20,8 @@ pub enum MenuAction {
Save,
OpenInBrowser,
OpenDataFolder,
ConnectServer,
DisconnectServer,
// Edit (custom items only)
Find,
Replace,
@@ -60,6 +62,8 @@ impl MenuAction {
MenuAction::Save,
MenuAction::OpenInBrowser,
MenuAction::OpenDataFolder,
MenuAction::ConnectServer,
MenuAction::DisconnectServer,
MenuAction::Find,
MenuAction::Replace,
MenuAction::EditPreferences,
@@ -95,6 +99,8 @@ impl MenuAction {
"save" => Self::Save,
"open_in_browser" => Self::OpenInBrowser,
"open_data_folder" => Self::OpenDataFolder,
"connect_server" => Self::ConnectServer,
"disconnect_server" => Self::DisconnectServer,
"find" => Self::Find,
"replace" => Self::Replace,
"edit_preferences" => Self::EditPreferences,
@@ -133,6 +139,8 @@ impl MenuAction {
Self::Save => "menu.item.save",
Self::OpenInBrowser => "menu.item.openInBrowser",
Self::OpenDataFolder => "menu.item.openDataFolder",
Self::ConnectServer => "menu.item.connectServer",
Self::DisconnectServer => "menu.item.disconnectServer",
Self::Find => "menu.item.find",
Self::Replace => "menu.item.replace",
Self::EditPreferences => "menu.item.editPreferences",
@@ -342,6 +350,9 @@ pub fn build_menu_bar(locale: UiLocale) -> (Menu, MenuRegistry) {
let _ = file_menu.append(&item(&mut reg, MenuAction::OpenInBrowser, locale, None));
let _ = file_menu.append(&item(&mut reg, MenuAction::OpenDataFolder, locale, None));
let _ = file_menu.append(&PredefinedMenuItem::separator());
let _ = file_menu.append(&item(&mut reg, MenuAction::ConnectServer, locale, None));
let _ = file_menu.append(&item(&mut reg, MenuAction::DisconnectServer, locale, None));
let _ = file_menu.append(&PredefinedMenuItem::separator());
let _ = file_menu.append(&PredefinedMenuItem::close_window(None));
// -- Edit --
@@ -523,16 +534,18 @@ pub fn update_menu_labels(registry: &MenuRegistry, locale: UiLocale) {
}
}
/// Iced subscription that polls muda `MenuEvent`s each frame.
/// Iced subscription that polls muda `MenuEvent`s independently of window input.
///
/// Produces `Message::MenuEvent(MenuId)` so the app can look up the
/// `MenuAction` via its `MenuRegistry`.
/// `MenuAction` via its `MenuRegistry`. Native menu selection does not itself
/// produce an Iced window event, so an `iced::event` listener would leave the
/// action queued until the user next moved or clicked inside the window.
pub fn menu_subscription() -> Subscription<Message> {
iced::event::listen_with(|_event, _status, _id| {
iced::time::every(std::time::Duration::from_millis(50)).map(|_| {
if let Ok(event) = MenuEvent::receiver().try_recv() {
Some(Message::MenuEvent(event.id))
Message::MenuEvent(event.id)
} else {
None
Message::Noop
}
})
}

View File

@@ -104,6 +104,14 @@ pub enum ModalState {
source_language: String,
available_targets: Vec<LanguageTarget>,
},
RemoteConnection {
target: String,
connecting: bool,
connected: bool,
error: Option<String>,
projects: Vec<bds_core::model::Project>,
selected_project_id: Option<String>,
},
}
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -219,6 +227,119 @@ pub fn view(
data_dir: Option<&Path>,
) -> Element<'static, Message> {
let modal_content: Element<'static, Message> = match state {
ModalState::RemoteConnection {
target,
connecting,
connected,
error,
projects,
selected_project_id,
} => {
let target_input =
text_input(&t(locale, "remoteConnection.targetPlaceholder"), &target)
.on_input(Message::RemoteTargetChanged)
.on_submit(Message::RemoteConnectRequested)
.style(inputs::field_style);
let mut content = column![
text(t(locale, "remoteConnection.title"))
.size(16)
.shaping(Shaping::Advanced)
.color(Color::WHITE),
text(t(locale, "remoteConnection.description"))
.size(12)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.70, 0.70, 0.75)),
Space::with_height(8.0),
text(t(locale, "remoteConnection.target"))
.size(12)
.color(Color::from_rgb(0.80, 0.80, 0.85)),
target_input,
]
.spacing(6)
.width(Length::Fill);
if let Some(error) = error {
content = content.push(
inputs::card(text(error).size(12).color(Color::from_rgb(1.0, 0.55, 0.55)))
.width(Length::Fill),
);
}
if !projects.is_empty() {
content = content.push(Space::with_height(6.0));
content = content.push(
text(t(locale, "remoteConnection.projects"))
.size(13)
.color(Color::WHITE),
);
let mut project_rows = column![].spacing(6);
for project in projects {
let selected = selected_project_id.as_deref() == Some(project.id.as_str());
let marker = if selected { "" } else { "" };
project_rows = project_rows.push(
button(
row![
text(marker).size(12),
text(project.name.clone()).size(13),
Space::with_width(Length::Fill),
text(project.description.unwrap_or_default())
.size(11)
.color(Color::from_rgb(0.65, 0.65, 0.70)),
]
.spacing(8)
.align_y(Alignment::Center),
)
.on_press(Message::RemoteProjectSelected(project.id))
.padding([7, 10])
.width(Length::Fill)
.style(if selected {
inputs::primary_button
} else {
inputs::secondary_button
}),
);
}
content = content.push(project_rows);
}
let cancel = button(text(t(locale, "remoteConnection.cancel")).size(13))
.on_press(Message::DismissModal)
.padding([6, 16])
.style(cancel_button_style);
let action = if !connected {
let button = button(
text(if connecting {
t(locale, "remoteConnection.connecting")
} else {
t(locale, "remoteConnection.connect")
})
.size(13),
)
.padding([6, 16])
.style(confirm_button_style);
if connecting || target.trim().is_empty() {
button
} else {
button.on_press(Message::RemoteConnectRequested)
}
} else {
let button = button(text(t(locale, "remoteConnection.open")).size(13))
.padding([6, 16])
.style(confirm_button_style);
if selected_project_id.is_some() {
button.on_press(Message::RemoteOpenProjectRequested)
} else {
button
}
};
content = content.push(Space::with_height(8.0)).push(
row![cancel, Space::with_width(Length::Fill), action].align_y(Alignment::Center),
);
container(content.padding(20))
.width(Length::Fixed(500.0))
.style(modal_box_style)
.into()
}
ModalState::ConfirmDelete {
entity_name,
references,

View File

@@ -712,3 +712,29 @@ import-sidebar-pending = Nicht analysiert
import-deleteTitle = Importdefinition löschen?
import-deleteMessage = „{ $name }“ und die gespeicherte Analyse löschen? Bereits importierte Projektinhalte bleiben erhalten.
import-toast-deleted = Importdefinition gelöscht.
menu-item-connectServer = Mit Server verbinden…
menu-item-disconnectServer = Serververbindung trennen
remoteConnection-title = Mit RuDS-Server verbinden
remoteConnection-description = Die private RuDS-SSH-Identität wird verwendet. Bei der ersten Verbindung wird der Host-Schlüssel gespeichert.
remoteConnection-target = Server
remoteConnection-targetPlaceholder = benutzer@host oder benutzer@host:port
remoteConnection-connect = Verbinden
remoteConnection-connecting = Verbindung wird hergestellt…
remoteConnection-projects = Entfernte Projekte
remoteConnection-open = Projekt öffnen
remoteConnection-cancel = Abbrechen
remoteConnection-connectionLost = Die Serververbindung ist nicht mehr verfügbar.
remoteConnection-opened = Entferntes Projekt geöffnet.
remoteConnection-disconnected = Verbindung zum RuDS-Server getrennt.
remoteConnection-eventReceived = Entferntes Ereignis #{ $sequence }: { $event }
remoteConnection-taskUpdate = Entfernte Aufgabenaktualisierung: { $count } Aufgaben.
remoteConnection-invalidTarget = Verwenden Sie das Format benutzer@host oder benutzer@host:port.
remoteConnection-failed = Verbindung zum RuDS-Server fehlgeschlagen: { $reason }
remoteConnection-openFailed = Das entfernte Projekt konnte nicht geöffnet werden: { $reason }
remoteTerminal-serverTitle = RuDS-Serversitzung
remoteTerminal-localTitle = RuDS-Terminalsitzung
remoteTerminal-availableProjects = Verfügbare Projekte
remoteTerminal-quit = q zum Beenden
remoteTerminal-serverListening = RuDS-SSH-Server hört auf { $address }
remoteTerminal-authorizedKeys = Autorisierte Schlüssel: { $path }
remoteTerminal-headlessFallback = Keine grafische Sitzung verfügbar; die Terminaloberfläche wird gestartet.

View File

@@ -712,3 +712,29 @@ import-sidebar-pending = Not analyzed
import-deleteTitle = Delete import definition?
import-deleteMessage = Delete “{ $name }” and its saved analysis? Imported project content is not removed.
import-toast-deleted = Import definition deleted.
menu-item-connectServer = Connect to Server…
menu-item-disconnectServer = Disconnect from Server
remoteConnection-title = Connect to RuDS Server
remoteConnection-description = Use your private RuDS SSH identity. The first connection records the server host key.
remoteConnection-target = Server
remoteConnection-targetPlaceholder = user@host or user@host:port
remoteConnection-connect = Connect
remoteConnection-connecting = Connecting…
remoteConnection-projects = Remote projects
remoteConnection-open = Open Project
remoteConnection-cancel = Cancel
remoteConnection-connectionLost = The remote connection is no longer available.
remoteConnection-opened = Remote project opened.
remoteConnection-disconnected = Disconnected from the RuDS server.
remoteConnection-eventReceived = Remote event #{ $sequence }: { $event }
remoteConnection-taskUpdate = Remote task update: { $count } tasks.
remoteConnection-invalidTarget = Use the form user@host or user@host:port.
remoteConnection-failed = Could not connect to the RuDS server: { $reason }
remoteConnection-openFailed = Could not open the remote project: { $reason }
remoteTerminal-serverTitle = RuDS server session
remoteTerminal-localTitle = RuDS terminal session
remoteTerminal-availableProjects = Available projects
remoteTerminal-quit = q to quit
remoteTerminal-serverListening = RuDS SSH server listening on { $address }
remoteTerminal-authorizedKeys = Authorized keys: { $path }
remoteTerminal-headlessFallback = No graphical session is available; starting the terminal UI.

View File

@@ -712,3 +712,29 @@ import-sidebar-pending = Sin analizar
import-deleteTitle = ¿Eliminar la definición de importación?
import-deleteMessage = ¿Eliminar «{ $name }» y su análisis guardado? El contenido ya importado se conservará.
import-toast-deleted = Definición de importación eliminada.
menu-item-connectServer = Conectar al servidor…
menu-item-disconnectServer = Desconectar del servidor
remoteConnection-title = Conectar al servidor RuDS
remoteConnection-description = Se usa la identidad SSH privada de RuDS. La clave de host del servidor se guarda en la primera conexión.
remoteConnection-target = Servidor
remoteConnection-targetPlaceholder = usuario@host o usuario@host:puerto
remoteConnection-connect = Conectar
remoteConnection-connecting = Conectando…
remoteConnection-projects = Proyectos remotos
remoteConnection-open = Abrir proyecto
remoteConnection-cancel = Cancelar
remoteConnection-connectionLost = La conexión remota ya no está disponible.
remoteConnection-opened = Proyecto remoto abierto.
remoteConnection-disconnected = Desconectado del servidor RuDS.
remoteConnection-eventReceived = Evento remoto n.º { $sequence }: { $event }
remoteConnection-taskUpdate = Actualización de tareas remotas: { $count } tareas.
remoteConnection-invalidTarget = Usa el formato usuario@host o usuario@host:puerto.
remoteConnection-failed = No se pudo conectar al servidor RuDS: { $reason }
remoteConnection-openFailed = No se pudo abrir el proyecto remoto: { $reason }
remoteTerminal-serverTitle = Sesión de servidor RuDS
remoteTerminal-localTitle = Sesión de terminal RuDS
remoteTerminal-availableProjects = Proyectos disponibles
remoteTerminal-quit = q para salir
remoteTerminal-serverListening = El servidor SSH de RuDS escucha en { $address }
remoteTerminal-authorizedKeys = Claves autorizadas: { $path }
remoteTerminal-headlessFallback = No hay una sesión gráfica disponible; se iniciará la interfaz de terminal.

View File

@@ -712,3 +712,29 @@ import-sidebar-pending = Non analysé
import-deleteTitle = Supprimer la définition dimport ?
import-deleteMessage = Supprimer « { $name } » et son analyse enregistrée ? Le contenu déjà importé sera conservé.
import-toast-deleted = Définition dimport supprimée.
menu-item-connectServer = Se connecter au serveur…
menu-item-disconnectServer = Se déconnecter du serveur
remoteConnection-title = Se connecter au serveur RuDS
remoteConnection-description = Lidentité SSH privée de RuDS est utilisée. La clé dhôte du serveur est enregistrée lors de la première connexion.
remoteConnection-target = Serveur
remoteConnection-targetPlaceholder = utilisateur@hôte ou utilisateur@hôte:port
remoteConnection-connect = Se connecter
remoteConnection-connecting = Connexion…
remoteConnection-projects = Projets distants
remoteConnection-open = Ouvrir le projet
remoteConnection-cancel = Annuler
remoteConnection-connectionLost = La connexion distante nest plus disponible.
remoteConnection-opened = Projet distant ouvert.
remoteConnection-disconnected = Déconnecté du serveur RuDS.
remoteConnection-eventReceived = Événement distant nº { $sequence } : { $event }
remoteConnection-taskUpdate = Mise à jour des tâches distantes : { $count } tâches.
remoteConnection-invalidTarget = Utilisez le format utilisateur@hôte ou utilisateur@hôte:port.
remoteConnection-failed = Impossible de se connecter au serveur RuDS : { $reason }
remoteConnection-openFailed = Impossible douvrir le projet distant : { $reason }
remoteTerminal-serverTitle = Session serveur RuDS
remoteTerminal-localTitle = Session terminal RuDS
remoteTerminal-availableProjects = Projets disponibles
remoteTerminal-quit = q pour quitter
remoteTerminal-serverListening = Le serveur SSH RuDS écoute sur { $address }
remoteTerminal-authorizedKeys = Clés autorisées : { $path }
remoteTerminal-headlessFallback = Aucune session graphique nest disponible ; démarrage de linterface terminal.

View File

@@ -712,3 +712,29 @@ import-sidebar-pending = Non analizzato
import-deleteTitle = Eliminare la definizione di importazione?
import-deleteMessage = Eliminare “{ $name }” e lanalisi salvata? I contenuti già importati resteranno invariati.
import-toast-deleted = Definizione di importazione eliminata.
menu-item-connectServer = Connetti al server…
menu-item-disconnectServer = Disconnetti dal server
remoteConnection-title = Connetti al server RuDS
remoteConnection-description = Viene usata lidentità SSH privata di RuDS. Alla prima connessione viene registrata la chiave host del server.
remoteConnection-target = Server
remoteConnection-targetPlaceholder = utente@host o utente@host:porta
remoteConnection-connect = Connetti
remoteConnection-connecting = Connessione…
remoteConnection-projects = Progetti remoti
remoteConnection-open = Apri progetto
remoteConnection-cancel = Annulla
remoteConnection-connectionLost = La connessione remota non è più disponibile.
remoteConnection-opened = Progetto remoto aperto.
remoteConnection-disconnected = Disconnesso dal server RuDS.
remoteConnection-eventReceived = Evento remoto n. { $sequence }: { $event }
remoteConnection-taskUpdate = Aggiornamento attività remote: { $count } attività.
remoteConnection-invalidTarget = Usa il formato utente@host o utente@host:porta.
remoteConnection-failed = Impossibile connettersi al server RuDS: { $reason }
remoteConnection-openFailed = Impossibile aprire il progetto remoto: { $reason }
remoteTerminal-serverTitle = Sessione server RuDS
remoteTerminal-localTitle = Sessione terminale RuDS
remoteTerminal-availableProjects = Progetti disponibili
remoteTerminal-quit = q per uscire
remoteTerminal-serverListening = Il server SSH RuDS è in ascolto su { $address }
remoteTerminal-authorizedKeys = Chiavi autorizzate: { $path }
remoteTerminal-headlessFallback = Non è disponibile una sessione grafica; avvio dellinterfaccia terminale.

View File

@@ -64,20 +64,23 @@ rule ServerBoot {
-- HTTP endpoint stays bound to 127.0.0.1; never exposed directly
ensures: CliSyncWatcherStarted()
ensures: SshDaemonStarted()
-- ExRatatui.SSH.Daemon: public-key auth only (no passwords),
-- tcpip_tunnel_out enabled so GUI clients tunnel to the loopback
-- endpoint through the same SSH connection and the same keys
-- Public-key auth only (no passwords). Direct TCP/IP forwarding is
-- restricted to the server-owned loopback endpoint; the native GUI
-- protocol and terminal sessions use channels on the same connection.
ensures: SshKeyMaterial.created()
-- host key generated and authorized_keys touched on first boot only
}
rule GuiRemoteConnection {
when: GuiConnectRequested()
-- Desktop "Connect to Server…": ssh connect with the same public keys
-- (client identity + known_hosts in the private ssh dir), then an OTP
-- TCP/IP tunnel to the server's loopback endpoint; the webview loads
-- the local tunnel end. Disconnect returns to the local shell URL.
ensures: WebviewTunneledToServer()
-- Desktop "Connect to Server…": SSH connects with the same public keys
-- (client identity + known_hosts in the private ssh dir), negotiates the
-- versioned `ruds` subsystem, lists server projects, and opens the selected
-- project through the shared CoreHost application-service operations. The
-- channel carries ordered domain events, task snapshots, and the server UI
-- locale. Request IDs make replayed writes exactly-once. Disconnect returns
-- to the local project without exposing or copying the SQLite database.
ensures: NativeRemoteProjectOpened()
}
rule TuiSession {