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