From 77cb29f0528de10d1f7afcf84f8f31beee506685 Mon Sep 17 00:00:00 2001 From: techartdev Date: Tue, 15 Sep 2026 19:01:22 +0300 Subject: [PATCH 1/3] Recover terminal startup when an SSH host disconnects on SFTP --- crates/ssh-core/examples/ssh_startup_probe.rs | 75 ++++++++ crates/ssh-core/src/connection.rs | 55 +++++- .../ssh-core/src/connection_recovery_tests.rs | 167 ++++++++++++++++++ crates/ssh-core/src/shell_files.rs | 11 ++ crates/ssh-core/src/shell_files_tests.rs | 9 + docs/host-profiles.md | 2 + src-tauri/src/lib.rs | 33 +++- 7 files changed, 340 insertions(+), 12 deletions(-) create mode 100644 crates/ssh-core/examples/ssh_startup_probe.rs create mode 100644 crates/ssh-core/src/connection_recovery_tests.rs diff --git a/crates/ssh-core/examples/ssh_startup_probe.rs b/crates/ssh-core/examples/ssh_startup_probe.rs new file mode 100644 index 0000000..7cd145a --- /dev/null +++ b/crates/ssh-core/examples/ssh_startup_probe.rs @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Read-only startup diagnostics; no files or settings are modified remotely. +use shellcanvas_core::*; +use std::{path::PathBuf, sync::Arc}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args: Vec<_> = std::env::args().collect(); + anyhow::ensure!( + args.len() == 5, + "Usage: ssh_startup_probe HOST USER KEY_PATH APP_KNOWN_HOSTS" + ); + let options = ConnectOptions { + host: args[1].clone(), + port: 22, + username: args[2].clone(), + key_path: args[3].clone(), + password: None, + passphrase: None, + allow_legacy_mac: true, + }; + let mut connection = + Arc::new(Connection::connect_with_trust_store(&options, PathBuf::from(&args[4])).await?); + println!( + "Authenticated; connected={}", + !connection.handle.is_closed() + ); + let info = inspect_host(&connection).await; + println!( + "Inspected: provider={}, system={}; connected={}", + info.provider, + info.system, + !connection.handle.is_closed() + ); + for notice in &info.notices { + println!("Notice: {notice}"); + } + if !connection.handle.is_closed() { + match connection.text_files().await { + Ok(service) => { + println!( + "SFTP initialized; connected={}", + !connection.handle.is_closed() + ); + let browser = SftpBrowser(Arc::new(service)); + println!( + "SFTP home resolved={}; connected={}", + browser.canonicalize(".").await.is_ok(), + !connection.handle.is_closed() + ); + } + Err(error) => println!( + "SFTP failed: {error:#}; connected={}", + !connection.handle.is_closed() + ), + } + } + if connection.handle.is_closed() { + connection = Arc::new(connection.reconnect(&options).await?); + println!( + "Recovered with the same verified host key; connected={}", + !connection.handle.is_closed() + ); + } + // Allocate and close a terminal without sending input or displaying its + // banner/history. This is the operation the recovered workspace needs. + let terminal = connection.terminal(80, 24).await?; + println!( + "Terminal opened; connected={}", + !connection.handle.is_closed() + ); + terminal.close().await?; + connection.disconnect().await?; + Ok(()) +} diff --git a/crates/ssh-core/src/connection.rs b/crates/ssh-core/src/connection.rs index 0f44800..79ea1da 100644 --- a/crates/ssh-core/src/connection.rs +++ b/crates/ssh-core/src/connection.rs @@ -9,13 +9,17 @@ use russh_sftp::client::SftpSession; use serde::Deserialize; use std::{ path::{Path, PathBuf}, - sync::Arc, + sync::{Arc, OnceLock}, time::Duration, }; use tokio::time::timeout; pub const OP_TIMEOUT: Duration = Duration::from_secs(15); +#[cfg(test)] +#[path = "connection_recovery_tests.rs"] +mod recovery_tests; + // Never derives Debug or Serialize: authentication material must not enter logs. #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -36,6 +40,7 @@ pub struct VerifiedHost { known_hosts: PathBuf, additional_known_hosts: Option, approved_key: Option, + verified_key: Arc>, } pub fn verify_host_key(host: &str, port: u16, key: &keys::PublicKey, path: &Path) -> Result<()> { @@ -62,12 +67,22 @@ impl client::Handler for VerifiedHost { &self.known_hosts, self.additional_known_hosts.as_deref(), )?; + let verified = self.verified_key.get_or_init(|| key.public_key()); + if verified.key_data() != key.public_key().key_data() { + bail!("Host key changed during the SSH connection"); + } Ok(true) } } pub struct Connection { pub handle: client::Handle, + host: String, + port: u16, + username: String, + host_key: keys::PublicKey, + known_hosts: PathBuf, + additional_known_hosts: Option, } #[async_trait::async_trait] @@ -82,6 +97,25 @@ impl shellcanvas_services::ConnectionLifecycle for Connection { } impl Connection { + /// Recover startup after an optional probe closes the transport. Authenticate + /// only the same account/endpoint and exact previously verified host key; + /// trust files are rechecked, and no failed operation is retried here. + pub async fn reconnect(&self, options: &ConnectOptions) -> Result { + if options.host != self.host + || options.port != self.port + || options.username != self.username + { + bail!("SSH recovery must use the original host and account"); + } + Self::connect_using( + options, + self.known_hosts.clone(), + self.additional_known_hosts.clone(), + Some(self.host_key.clone()), + ) + .await + } + pub async fn connect(options: ConnectOptions) -> Result { let known_hosts = dirs::home_dir() .context("Cannot locate your home directory")? @@ -128,12 +162,14 @@ impl Connection { { bail!("A host, username, and valid port are required."); } + let verified_key = Arc::new(OnceLock::new()); let handler = VerifiedHost { host: options.host.clone(), port: options.port, - known_hosts, - additional_known_hosts, + known_hosts: known_hosts.clone(), + additional_known_hosts: additional_known_hosts.clone(), approved_key, + verified_key: verified_key.clone(), }; let config = client::Config { preferred: ssh_preferences(options.allow_legacy_mac), @@ -203,7 +239,18 @@ impl Connection { if !auth.success() { bail!("Authentication rejected. Check the username and authentication method."); } - Ok(Self { handle }) + Ok(Self { + handle, + host: options.host.clone(), + port: options.port, + username: options.username.clone(), + host_key: verified_key + .get() + .context("SSH host key was not verified")? + .clone(), + known_hosts, + additional_known_hosts, + }) }) .await .context("Connection timed out after 30 seconds")? diff --git a/crates/ssh-core/src/connection_recovery_tests.rs b/crates/ssh-core/src/connection_recovery_tests.rs new file mode 100644 index 0000000..1bd2530 --- /dev/null +++ b/crates/ssh-core/src/connection_recovery_tests.rs @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: MPL-2.0 +use super::*; +use russh::{server, ChannelId}; +use std::{ + collections::HashMap, + sync::atomic::{AtomicUsize, Ordering}, +}; +use tokio::net::TcpListener; + +struct DropsSftp { + auth: Arc, + sftp: Arc, + channels: HashMap>, +} +impl server::Handler for DropsSftp { + type Error = russh::Error; + async fn auth_password( + &mut self, + _: &str, + _: &str, + ) -> std::result::Result { + self.auth.fetch_add(1, Ordering::SeqCst); + Ok(server::Auth::Accept) + } + async fn channel_open_session( + &mut self, + channel: Channel, + reply: server::ChannelOpenHandle, + _: &mut server::Session, + ) -> std::result::Result<(), Self::Error> { + self.channels.insert(channel.id(), channel); + reply.accept().await; + Ok(()) + } + async fn subsystem_request( + &mut self, + _: ChannelId, + name: &str, + session: &mut server::Session, + ) -> std::result::Result<(), Self::Error> { + assert_eq!(name, "sftp"); + self.sftp.fetch_add(1, Ordering::SeqCst); + session.disconnect( + russh::Disconnect::ServiceNotAvailable, + "SFTP unavailable", + "en", + ) + } + async fn pty_request( + &mut self, + id: ChannelId, + _: &str, + _: u32, + _: u32, + _: u32, + _: u32, + _: &[(russh::Pty, u32)], + session: &mut server::Session, + ) -> std::result::Result<(), Self::Error> { + session.channel_success(id) + } + async fn shell_request( + &mut self, + id: ChannelId, + session: &mut server::Session, + ) -> std::result::Result<(), Self::Error> { + session.channel_success(id) + } +} + +#[tokio::test] +async fn sftp_disconnect_recovers_terminal_but_never_changes_host_identity() { + for changed_key in [false, true] { + let first_key = + keys::PrivateKey::random(&mut rand::rng(), keys::Algorithm::Ed25519).unwrap(); + let second_key = if changed_key { + keys::PrivateKey::random(&mut rand::rng(), keys::Algorithm::Ed25519).unwrap() + } else { + first_key.clone() + }; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let directory = tempfile::tempdir().unwrap(); + let known_hosts = directory.path().join("known_hosts"); + // Even a second explicitly trusted key cannot replace this session's + // identity during automatic recovery. + std::fs::write( + &known_hosts, + format!( + "[127.0.0.1]:{port} {}\n[127.0.0.1]:{port} {}\n", + first_key.public_key().to_openssh().unwrap(), + second_key.public_key().to_openssh().unwrap() + ), + ) + .unwrap(); + let auth = Arc::new(AtomicUsize::new(0)); + let sftp = Arc::new(AtomicUsize::new(0)); + let (auth_count, sftp_count) = (auth.clone(), sftp.clone()); + let server = tokio::spawn(async move { + for key in [first_key, second_key] { + let (stream, _) = listener.accept().await.unwrap(); + let config = Arc::new(server::Config { + keys: vec![key], + auth_rejection_time: Duration::ZERO, + ..Default::default() + }); + let handler = DropsSftp { + auth: auth_count.clone(), + sftp: sftp_count.clone(), + channels: HashMap::new(), + }; + if let Ok(session) = server::run_stream(config, stream, handler).await { + let _ = session.await; + } + } + }); + let mut options = ConnectOptions { + host: "127.0.0.1".into(), + port, + username: "fixture".into(), + password: Some("local-test-only".into()), + key_path: String::new(), + passphrase: None, + allow_legacy_mac: false, + }; + let connection = Connection::connect_using(&options, known_hosts, None, None) + .await + .unwrap(); + assert!(connection.text_files().await.is_err()); + timeout(Duration::from_secs(2), async { + while !connection.handle.is_closed() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + options.username = "another-account".into(); + assert!(connection.reconnect(&options).await.is_err()); + options.username = "fixture".into(); + let recovered = connection.reconnect(&options).await; + if changed_key { + let message = recovered.err().expect("changed key must fail").to_string(); + assert!(message.contains("SSH connection failed")); + assert_eq!( + auth.load(Ordering::SeqCst), + 1, + "replacement key received authentication" + ); + } else { + let recovered = recovered.unwrap(); + let terminal = recovered.terminal(80, 24).await.unwrap(); + assert!(!recovered.handle.is_closed()); + terminal.close().await.unwrap(); + recovered.disconnect().await.unwrap(); + assert_eq!(auth.load(Ordering::SeqCst), 2); + } + assert_eq!( + sftp.load(Ordering::SeqCst), + 1, + "recovery must not retry SFTP" + ); + timeout(Duration::from_secs(5), server) + .await + .unwrap() + .unwrap(); + } +} diff --git a/crates/ssh-core/src/shell_files.rs b/crates/ssh-core/src/shell_files.rs index 5454452..392b80c 100644 --- a/crates/ssh-core/src/shell_files.rs +++ b/crates/ssh-core/src/shell_files.rs @@ -213,6 +213,17 @@ pub struct ShellFiles { uploads: bool, } impl ShellFiles { + /// Recognized command languages must not receive POSIX shell scripts. + pub async fn probe_for_host(connection: Arc, provider: &str) -> Result { + if matches!(provider, "routeros" | "windows") { + bail!("POSIX shell file access is unavailable for {provider}"); + } + if connection.handle.is_closed() { + bail!("SSH connection closed before shell file probing"); + } + Self::probe(connection).await + } + /// All probing is read-only. Incompatible/restricted shells fail closed. pub async fn probe(connection: Arc) -> Result { let bytes = collect(&connection, PROBE, FIELD_LIMIT).await?; diff --git a/crates/ssh-core/src/shell_files_tests.rs b/crates/ssh-core/src/shell_files_tests.rs index 7af7efe..06d1f0b 100644 --- a/crates/ssh-core/src/shell_files_tests.rs +++ b/crates/ssh-core/src/shell_files_tests.rs @@ -335,6 +335,15 @@ async fn restricted_shell_and_command_failures_fail_closed() { assert!(ShellFiles::probe(fixture.connection.clone()).await.is_err()); assert!(!fixture.connection.handle.is_closed()); let fixture = Fixture::new(false).await; + for provider in ["routeros", "windows"] { + let result = ShellFiles::probe_for_host(fixture.connection.clone(), provider).await; + assert!(result + .err() + .unwrap() + .to_string() + .contains("POSIX shell file access is unavailable")); + assert!(!fixture.connection.handle.is_closed()); + } assert!(collect(&fixture.connection, "printf partial; exit 1", 100) .await .is_err()); diff --git a/docs/host-profiles.md b/docs/host-profiles.md index e289e7b..7484003 100644 --- a/docs/host-profiles.md +++ b/docs/host-profiles.md @@ -24,6 +24,8 @@ For a bounded read-only authentication and interactive-terminal check against a The built-in `routeros` system provider recognizes the RouterOS version and system identity through fixed read-only commands. Host details shows **MikroTik RouterOS** with its reported version rather than Generic SSH. Identification does not invent Linux settings, disk-management actions or file-service capabilities. Terminal and SFTP availability still come from the connected device. Read-only account permissions must permit the probes; otherwise generic SSH access remains available. +Some RouterOS configurations close the entire SSH transport when SFTP is requested. Startup then reconnects once for terminal access, requiring the same host/account, the exact previously verified host key and current trust-file approval. It discards file services from the closed connection and does not retry SFTP. RouterOS is excluded from the POSIX shell fallback. A notice explains why files are unavailable; no router settings or account permissions are changed. This recovery was verified against RouterOS 6.49.19, whose SFTP request was also rejected by OpenSSH. + The commands use RouterOS [`get` and `:put`](https://help.mikrotik.com/docs/spaces/ROS/pages/8978498/Console). See [desktop clock](desktop-clock.md) and [confirmed text saves](text-editor.md#servers-without-atomic-replacement) for the appliance-specific behavior. Recognition, remote time and confirmed saving were verified against RouterOS 6.49.19; this does not claim comprehensive RouterOS administration support. For a read-only check use `cargo run -p shellcanvas-core --example routeros_probe -- HOST USER KEY_PATH`. This probe enables legacy compatibility for its connection. The optional `--save-test` flag requires permission to create, overwrite and remove one uniquely named disposable file in the SFTP root. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 62c83c3..313fbb8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -218,20 +218,16 @@ async fn prepare_ssh( .map_err(|e| format!("{e:#}"))? } }; - let connection = Arc::new(connection); + let mut connection = Arc::new(connection); let mut info = inspect_host(&connection).await; if options.allow_legacy_mac { info.notices.push("Legacy SSH compatibility enabled: HMAC-SHA1, RSA/SHA1 authentication and 2048-bit exchange groups are allowed when needed. Modern algorithms remain preferred; MD5 is disabled.".into()); } - let settings = settings_for_host(&info.provider, Some(connection.clone())); - if settings.is_some() { - info.capabilities.push("host.settings".into()); - } let mut files: Option> = None; let mut mutations: Option> = None; let mut moves: Option> = None; let mut transfers: Option> = None; - let text: Option> = match connection.text_files().await { + let mut text: Option> = match connection.text_files().await { Ok(service) => { let service = service.with_identified_provider(&info.provider); info.capabilities.push("files.edit".into()); @@ -260,7 +256,7 @@ async fn prepare_ssh( ]); Some(service) } - Err(error) => match ShellFiles::probe(connection.clone()).await { + Err(error) => match ShellFiles::probe_for_host(connection.clone(), &info.provider).await { Ok(service) => { info.home = Some(service.home().into()); info.capabilities.push("files.read".into()); @@ -280,11 +276,32 @@ async fn prepare_ssh( Some(service as Arc) } Err(shell_error) => { - info.notices.push(format!("File access unavailable: SFTP: {error}; SSH shell fallback: {shell_error}. The account must allow SFTP or compatible shell commands. Terminal access is independent.")); + info.notices.push(format!("File access unavailable: SFTP: {error:#}; SSH shell fallback: {shell_error:#}. The account must allow SFTP or compatible shell commands.")); None } }, }; + if connection.handle.is_closed() { + // Some appliances terminate SSH itself when they reject SFTP. Recover + // once before leasing the workspace; never retain services from the old + // transport or repeat the probe that just disconnected it. + connection = Arc::new(connection.reconnect(&options).await.map_err(|error| { + format!("The host closed SSH during file-service startup, and reconnecting for terminal access failed: {error:#}") + })?); + files = None; + text = None; + mutations = None; + moves = None; + transfers = None; + info.home = None; + info.capabilities + .retain(|capability| !capability.starts_with("files.")); + info.notices.push("The host closed SSH during file-service startup. Reconnected for terminal access; file services are unavailable for this workspace. Check the server's SFTP support and this account's file permissions.".into()); + } + let settings = settings_for_host(&info.provider, Some(connection.clone())); + if settings.is_some() { + info.capabilities.push("host.settings".into()); + } let clock = shellcanvas_core::clock::SshHostClock::for_provider(connection.clone(), &info.provider); let resource = ConnectionResource::with_clock( From dd4f4b35dcc1c329a63425c9856c26fd8199be08 Mon Sep 17 00:00:00 2001 From: techartdev Date: Wed, 16 Sep 2026 17:25:51 +0300 Subject: [PATCH 2/3] Add read-only RouterOS file browsing fallback --- crates/ssh-core/src/connection.rs | 11 +- crates/ssh-core/src/lib.rs | 2 + crates/ssh-core/src/routeros_files.rs | 383 ++++++++++++++++++++++++++ docs/host-profiles.md | 4 +- src-tauri/src/lib.rs | 23 +- 5 files changed, 419 insertions(+), 4 deletions(-) create mode 100644 crates/ssh-core/src/routeros_files.rs diff --git a/crates/ssh-core/src/connection.rs b/crates/ssh-core/src/connection.rs index 79ea1da..62f8344 100644 --- a/crates/ssh-core/src/connection.rs +++ b/crates/ssh-core/src/connection.rs @@ -262,6 +262,15 @@ impl Connection { /// Trusted provider command execution, never exposed as a desktop IPC command. pub(crate) async fn exec_bounded(&self, command: &str) -> Result { + Ok( + String::from_utf8_lossy(&self.exec_bounded_bytes(command).await?) + .trim() + .to_owned(), + ) + } + + /// Binary-safe variant for provider protocols that validate UTF-8 themselves. + pub(crate) async fn exec_bounded_bytes(&self, command: &str) -> Result> { timeout(OP_TIMEOUT, async { let mut channel = self.handle.channel_open_session().await?; channel.exec(true, command).await?; @@ -292,7 +301,7 @@ impl Connection { String::from_utf8_lossy(&stderr).trim() ); } - Ok(String::from_utf8_lossy(&bytes).trim().to_owned()) + Ok(bytes) }) .await .context("Host command timed out")? diff --git a/crates/ssh-core/src/lib.rs b/crates/ssh-core/src/lib.rs index f26a798..2785787 100644 --- a/crates/ssh-core/src/lib.rs +++ b/crates/ssh-core/src/lib.rs @@ -12,6 +12,8 @@ pub mod mounted; pub mod probe; pub mod profiles; pub mod provider; +pub mod routeros_files; +pub use routeros_files::RouterOsFiles; pub mod settings; pub mod shell_files; pub use shell_files::ShellFiles; diff --git a/crates/ssh-core/src/routeros_files.rs b/crates/ssh-core/src/routeros_files.rs new file mode 100644 index 0000000..bc27068 --- /dev/null +++ b/crates/ssh-core/src/routeros_files.rs @@ -0,0 +1,383 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Read-only RouterOS `/file` metadata browsing. The command is fixed: paths are +//! interpreted locally and are never inserted into RouterOS source text. +use crate::Connection; +use anyhow::{bail, Context, Result}; +use async_trait::async_trait; +use shellcanvas_services::{Directory, FileEntry, FileLocation, FilePlace, FileSystemProvider}; +use std::{ + collections::{BTreeMap, HashSet}, + sync::Arc, +}; + +const HEADER: &str = "ShellCanvas-Files-1"; +const MAX_ENTRIES: usize = 4096; +const MAX_FIELD: usize = 16 * 1024; + +/// RouterOS 6 compatible: only `find`, `get`, `:len`, `:foreach`, and `:put`. +/// Each variable field follows an ASCII byte-length header, so embedded line +/// breaks and quotes cannot alter record boundaries. +pub const ROUTEROS_FILES_PROBE: &str = r#":put "ShellCanvas-Files-1"; :local c 0; :foreach f in=[/file find] do={:local n [/file get $f name]; :local t [/file get $f type]; :local s [/file get $f size]; :put ("SCF1|" . [:len $n] . "|" . [:len $t] . "|" . $s); :put $n; :put $t; :set c ($c + 1)}; :put ("SCEND|" . $c)"#; + +#[derive(Clone, Debug)] +struct Item { + name: String, + kind: String, + size: u64, +} + +fn take_line<'a>(bytes: &mut &'a [u8]) -> Result<&'a [u8]> { + let end = bytes + .iter() + .position(|byte| *byte == b'\n') + .context("Truncated RouterOS file response")?; + let mut line = &bytes[..end]; + if line.last() == Some(&b'\r') { + line = &line[..line.len() - 1]; + } + *bytes = &bytes[end + 1..]; + Ok(line) +} + +fn take_field<'a>(bytes: &mut &'a [u8], len: usize) -> Result<&'a [u8]> { + if len > MAX_FIELD || bytes.len() < len { + bail!("Invalid RouterOS file field length"); + } + let field = &bytes[..len]; + *bytes = &bytes[len..]; + if bytes.starts_with(b"\r\n") { + *bytes = &bytes[2..]; + } else if bytes.starts_with(b"\n") { + *bytes = &bytes[1..]; + } else { + bail!("Malformed RouterOS file field terminator"); + } + Ok(field) +} + +fn parse_snapshot(bytes: &[u8]) -> Result> { + let mut bytes = bytes; + if take_line(&mut bytes)? != HEADER.as_bytes() { + bail!("Invalid RouterOS file response header"); + } + let mut items = Vec::new(); + let mut names = HashSet::new(); + loop { + let header = std::str::from_utf8(take_line(&mut bytes)?) + .context("RouterOS file header is not UTF-8")?; + if let Some(count) = header.strip_prefix("SCEND|") { + let count: usize = count + .parse() + .context("Invalid RouterOS file record count")?; + if count != items.len() || !bytes.is_empty() { + bail!("Invalid RouterOS file response end marker"); + } + break; + } + let fields: Vec<_> = header.split('|').collect(); + if fields.len() != 4 || fields[0] != "SCF1" { + bail!("Malformed RouterOS file record"); + } + let name_len: usize = fields[1] + .parse() + .context("Invalid RouterOS file name length")?; + let type_len: usize = fields[2] + .parse() + .context("Invalid RouterOS file type length")?; + let size = if fields[3].is_empty() { + 0 + } else { + fields[3].parse().context("Invalid RouterOS file size")? + }; + let name = std::str::from_utf8(take_field(&mut bytes, name_len)?) + .context("RouterOS file name is not UTF-8")? + .to_owned(); + let kind = std::str::from_utf8(take_field(&mut bytes, type_len)?) + .context("RouterOS file type is not UTF-8")? + .to_owned(); + if name.is_empty() + || name.starts_with('/') + || name.ends_with('/') + || name.contains('\0') + || name + .split('/') + .any(|part| part.is_empty() || matches!(part, "." | "..")) + { + bail!("Invalid RouterOS file name"); + } + if !names.insert(name.clone()) { + bail!("Duplicate RouterOS file name"); + } + if items.len() == MAX_ENTRIES { + bail!("RouterOS file response has too many entries"); + } + items.push(Item { name, kind, size }); + } + Ok(items) +} + +fn validate_path(path: &str) -> Result<&str> { + if path == "/" { + return Ok(""); + } + let relative = path + .strip_prefix('/') + .context("RouterOS file paths must be absolute")?; + if relative.ends_with('/') + || relative.contains('\0') + || relative + .split('/') + .any(|part| part.is_empty() || matches!(part, "." | "..")) + { + bail!("Invalid RouterOS file path"); + } + Ok(relative) +} + +fn location(path: &str) -> Result { + let relative = validate_path(path)?; + if relative.is_empty() { + return Ok(FileLocation { + path: "/".into(), + name: "Files".into(), + parent: None, + }); + } + let (parent, name) = relative.rsplit_once('/').unwrap_or(("", relative)); + Ok(FileLocation { + path: path.into(), + name: name.into(), + parent: Some(if parent.is_empty() { + "/".into() + } else { + format!("/{parent}") + }), + }) +} + +fn is_directory(kind: &str) -> bool { + matches!(kind, "directory" | "disk") +} + +fn directory(items: &[Item], path: &str) -> Result { + let relative = validate_path(path)?; + if !relative.is_empty() { + let explicit = items.iter().find(|item| item.name == relative); + let has_children = items.iter().any(|item| { + item.name + .strip_prefix(relative) + .is_some_and(|tail| tail.starts_with('/')) + }); + if !has_children && !explicit.is_some_and(|item| is_directory(&item.kind)) { + bail!("RouterOS directory does not exist"); + } + } + let prefix = if relative.is_empty() { + String::new() + } else { + format!("{relative}/") + }; + let mut children: BTreeMap<&str, (&Item, bool)> = BTreeMap::new(); + for item in items { + let Some(tail) = item.name.strip_prefix(&prefix) else { + continue; + }; + if tail.is_empty() { + continue; + } + let (child, nested) = tail + .split_once('/') + .map_or((tail, false), |(head, _)| (head, true)); + children + .entry(child) + .and_modify(|value| value.1 |= nested) + .or_insert((item, nested)); + } + let entries = children + .into_iter() + .map(|(name, (item, nested))| { + let child_path = if relative.is_empty() { + format!("/{name}") + } else { + format!("/{relative}/{name}") + }; + let child_relative = child_path.trim_start_matches('/'); + let exact = item.name == child_relative; + let directory = nested || (exact && is_directory(&item.kind)); + FileEntry { + name: name.into(), + path: child_path, + kind: if directory { "directory" } else { "file" }.into(), + size: if exact && !directory { item.size } else { 0 }, + modified: None, + revision: format!("routeros:{}:{}:{}", item.kind, item.size, item.name), + } + }) + .collect(); + let current = location(path)?; + let roots = items + .iter() + .filter(|item| item.kind == "disk" && !item.name.contains('/')) + .map(|item| FilePlace { + path: format!("/{}", item.name), + name: item.name.clone(), + }) + .collect(); + Ok(Directory { + path: current.path, + name: current.name, + parent: current.parent, + home: Some(FilePlace { + path: "/".into(), + name: "Files".into(), + }), + roots, + entries, + }) +} + +pub struct RouterOsFiles { + connection: Arc, +} +impl RouterOsFiles { + pub async fn probe(connection: Arc) -> Result { + parse_snapshot(&connection.exec_bounded_bytes(ROUTEROS_FILES_PROBE).await?)?; + Ok(Self { connection }) + } + async fn snapshot(&self) -> Result> { + parse_snapshot( + &self + .connection + .exec_bounded_bytes(ROUTEROS_FILES_PROBE) + .await?, + ) + } +} + +#[async_trait] +impl FileSystemProvider for RouterOsFiles { + async fn list(&self, path: Option<&str>) -> Result { + directory(&self.snapshot().await?, path.unwrap_or("/")) + } + async fn locate(&self, path: &str) -> Result { + let items = self.snapshot().await?; + let relative = validate_path(path)?; + if relative.is_empty() + || items.iter().any(|item| item.name == relative) + || items.iter().any(|item| { + item.name + .strip_prefix(relative) + .is_some_and(|tail| tail.starts_with('/')) + }) + { + location(path) + } else { + bail!("RouterOS file does not exist") + } + } + async fn preview(&self, _path: &str) -> Result { + bail!("File-content preview is unavailable through RouterOS metadata browsing") + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn record(name: &[u8], kind: &[u8], size: &str) -> Vec { + let mut value = format!("SCF1|{}|{}|{}\r\n", name.len(), kind.len(), size).into_bytes(); + value.extend(name); + value.extend(b"\r\n"); + value.extend(kind); + value.extend(b"\r\n"); + value + } + fn snapshot(records: Vec>) -> Vec { + let count = records.len(); + let mut value = b"ShellCanvas-Files-1\r\n".to_vec(); + for record in records { + value.extend(record); + } + value.extend(format!("SCEND|{count}\r\n").as_bytes()); + value + } + #[test] + fn parses_framed_names_with_quotes_and_controls() { + let bytes = snapshot(vec![record(b"flash/a\n'b\t", b"file", "12")]); + let items = parse_snapshot(&bytes).unwrap(); + assert_eq!(items[0].name, "flash/a\n'b\t"); + assert_eq!(items[0].size, 12); + } + #[test] + fn rejects_truncated_malformed_duplicate_and_non_utf8_records() { + let valid = record(b"flash/a", b"file", "1"); + for bytes in [ + b"wrong\n".to_vec(), + snapshot(vec![b"SCF1|9|4|1\na".to_vec()]), + snapshot(vec![b"bad\n".to_vec()]), + snapshot(vec![valid.clone(), valid.clone()]), + snapshot(vec![record(&[0xff], b"file", "1")]), + b"ShellCanvas-Files-1\n".to_vec(), + [b"ShellCanvas-Files-1\n".as_slice(), valid.as_slice()].concat(), + ] { + assert!(parse_snapshot(&bytes).is_err()); + } + assert!(parse_snapshot(&snapshot(vec![])).unwrap().is_empty()); + } + #[test] + fn builds_safe_nested_directories_and_disk_roots() { + let items = parse_snapshot(&snapshot(vec![ + record(b"flash", b"disk", "0"), + record(b"flash/empty", b"directory", "0"), + record(b"flash/dir/file.txt", b"file", "9"), + record(b"root.txt", b"file", "3"), + record(b"flash2/not-in-flash", b"file", "4"), + ])) + .unwrap(); + let root = directory(&items, "/").unwrap(); + assert_eq!(root.roots[0].path, "/flash"); + assert_eq!( + root.entries + .iter() + .find(|e| e.name == "flash") + .unwrap() + .kind, + "directory" + ); + let flash = directory(&items, "/flash").unwrap(); + assert!(flash + .entries + .iter() + .any(|e| e.name == "empty" && e.kind == "directory")); + assert!(flash + .entries + .iter() + .any(|e| e.name == "dir" && e.kind == "directory")); + assert!(!flash + .entries + .iter() + .any(|e| e.name == "flash2" || e.name == "not-in-flash")); + assert!(directory(&items, "/flash/empty") + .unwrap() + .entries + .is_empty()); + for path in ["flash", "//flash", "/flash/../root.txt", "/flash/"] { + assert!(directory(&items, path).is_err()); + } + } + + #[tokio::test] + async fn snapshot_directory_pages_large_router_listing() { + use shellcanvas_services::{DirectoryReader, SnapshotDirectory, DIRECTORY_PAGE}; + let records = (0..(DIRECTORY_PAGE + 5)) + .map(|i| record(format!("file-{i:03}").as_bytes(), b"file", "1")) + .collect(); + let items = parse_snapshot(&snapshot(records)).unwrap(); + let mut reader = SnapshotDirectory::new(directory(&items, "/").unwrap()); + let first = reader.next().await.unwrap(); + assert_eq!(first.directory.entries.len(), DIRECTORY_PAGE); + assert!(!first.done); + let second = reader.next().await.unwrap(); + assert_eq!(second.directory.entries.len(), 5); + assert!(second.done); + } +} diff --git a/docs/host-profiles.md b/docs/host-profiles.md index 7484003..7b35d61 100644 --- a/docs/host-profiles.md +++ b/docs/host-profiles.md @@ -24,7 +24,9 @@ For a bounded read-only authentication and interactive-terminal check against a The built-in `routeros` system provider recognizes the RouterOS version and system identity through fixed read-only commands. Host details shows **MikroTik RouterOS** with its reported version rather than Generic SSH. Identification does not invent Linux settings, disk-management actions or file-service capabilities. Terminal and SFTP availability still come from the connected device. Read-only account permissions must permit the probes; otherwise generic SSH access remains available. -Some RouterOS configurations close the entire SSH transport when SFTP is requested. Startup then reconnects once for terminal access, requiring the same host/account, the exact previously verified host key and current trust-file approval. It discards file services from the closed connection and does not retry SFTP. RouterOS is excluded from the POSIX shell fallback. A notice explains why files are unavailable; no router settings or account permissions are changed. This recovery was verified against RouterOS 6.49.19, whose SFTP request was also rejected by OpenSSH. +Some RouterOS configurations close the entire SSH transport when SFTP is requested. Startup reconnects with the same host/account, exact previously verified host key and current trust-file approval, then tries a RouterOS-specific read-only metadata fallback. It does not retry SFTP and RouterOS remains excluded from the POSIX shell fallback. If the metadata command also closes SSH, startup makes one final pinned reconnect for terminal access and does not retry either file probe. + +The fallback runs one fixed RouterOS 6-compatible `/file` script and never inserts a selected path into a command. It exposes root, parent and home navigation, nested directories and detected disk roots. Only `files.read` browsing is advertised: file contents and previews, transfers, editing, creation, management and local drive attachment are unavailable. Responses are size- and time-bounded, strictly decoded as UTF-8, and rejected on invalid framing, duplicate names or unsafe paths. No router settings, permissions or file contents are changed. The commands use RouterOS [`get` and `:put`](https://help.mikrotik.com/docs/spaces/ROS/pages/8978498/Console). See [desktop clock](desktop-clock.md) and [confirmed text saves](text-editor.md#servers-without-atomic-replacement) for the appliance-specific behavior. Recognition, remote time and confirmed saving were verified against RouterOS 6.49.19; this does not claim comprehensive RouterOS administration support. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 313fbb8..10d53e1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -227,7 +227,8 @@ async fn prepare_ssh( let mut mutations: Option> = None; let mut moves: Option> = None; let mut transfers: Option> = None; - let mut text: Option> = match connection.text_files().await { + let sftp = connection.text_files().await; + let mut text: Option> = match sftp { Ok(service) => { let service = service.with_identified_provider(&info.provider); info.capabilities.push("files.edit".into()); @@ -256,6 +257,24 @@ async fn prepare_ssh( ]); Some(service) } + Err(error) if info.provider == "routeros" => { + if connection.handle.is_closed() { + connection = Arc::new(connection.reconnect(&options).await.map_err(|reconnect_error| { + format!("The host closed SSH after rejecting SFTP, and reconnecting for RouterOS file metadata failed: {reconnect_error:#}") + })?); + info.notices.push("The host closed SSH after rejecting SFTP. Reconnected with the verified host key before trying read-only RouterOS file metadata.".into()); + } + match shellcanvas_core::RouterOsFiles::probe(connection.clone()).await { + Ok(service) => { + info.home = Some("/".into()); + info.capabilities.push("files.read".into()); + info.notices.push("SFTP is unavailable. Using read-only RouterOS file metadata: folder browsing is available, while file contents, transfers, editing, management and local drive attachment are unavailable.".into()); + files = Some(Arc::new(service)); + } + Err(metadata_error) => info.notices.push(format!("File access unavailable: SFTP: {error:#}; RouterOS metadata: {metadata_error:#}. The account must permit read-only /file metadata.")), + } + None + } Err(error) => match ShellFiles::probe_for_host(connection.clone(), &info.provider).await { Ok(service) => { info.home = Some(service.home().into()); @@ -296,7 +315,7 @@ async fn prepare_ssh( info.home = None; info.capabilities .retain(|capability| !capability.starts_with("files.")); - info.notices.push("The host closed SSH during file-service startup. Reconnected for terminal access; file services are unavailable for this workspace. Check the server's SFTP support and this account's file permissions.".into()); + info.notices.push("The host closed SSH during file-service startup. Reconnected for terminal access; file services are unavailable for this workspace. Check the server's file-service support and this account's permissions.".into()); } let settings = settings_for_host(&info.provider, Some(connection.clone())); if settings.is_some() { From 008023a5cde0b0f1d9fa895ef4eb689920c71f5f Mon Sep 17 00:00:00 2001 From: techartdev Date: Wed, 16 Sep 2026 18:48:48 +0300 Subject: [PATCH 3/3] Prepare ShellCanvas 0.1.9 release --- CHANGELOG.md | 15 +++++++++++++++ Cargo.lock | 6 +++--- crates/adapter-sdk/Cargo.toml | 2 +- crates/filesystem-sdk/Cargo.toml | 2 +- package-lock.json | 6 +++--- package.json | 2 +- packages/app-sdk/package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 9 files changed, 27 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a52da65..29252fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ Notable changes to ShellCanvas, newest first. Published SDK packages follow [semantic versioning](https://semver.org/), and desktop releases use the same version where practical. The project is pre-1.0, so a minor release may include breaking changes; those come with migration notes. +## [0.1.9] - 2026-09-16 + +### Added + +- Browse RouterOS 6 file metadata when SFTP is unavailable. The read-only fallback supports root, home and parent navigation, nested and empty directories, and disk roots without inserting selected paths into router commands. + +### Fixed + +- Recover terminal startup when an appliance closes the SSH transport after rejecting SFTP. Reconnection keeps the same endpoint and account, requires the previously verified host key and current trust approval, and never retries the rejected SFTP request. + +### Known limitations + +- RouterOS metadata fallback does not read file contents or support previews, transfers, editing, management or local drive attachment. The full file manager requires working SFTP and account permission for file transfer. +- Remote Settings remains available only for supported Linux hosts; RouterOS does not expose that service. + ## [0.1.8] - 2026-09-14 ### Added diff --git a/Cargo.lock b/Cargo.lock index 3f16dc9..4b88d63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4978,7 +4978,7 @@ dependencies = [ [[package]] name = "shellcanvas" -version = "0.1.8" +version = "0.1.9" dependencies = [ "anyhow", "async-trait", @@ -5030,7 +5030,7 @@ dependencies = [ [[package]] name = "shellcanvas-adapter-sdk" -version = "0.1.8" +version = "0.1.9" dependencies = [ "anyhow", "async-trait", @@ -5068,7 +5068,7 @@ dependencies = [ [[package]] name = "shellcanvas-filesystem-sdk" -version = "0.1.8" +version = "0.1.9" dependencies = [ "async-trait", "serde", diff --git a/crates/adapter-sdk/Cargo.toml b/crates/adapter-sdk/Cargo.toml index b8cce5c..6fddd17 100644 --- a/crates/adapter-sdk/Cargo.toml +++ b/crates/adapter-sdk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shellcanvas-adapter-sdk" -version = "0.1.8" +version = "0.1.9" edition = "2021" license = "MPL-2.0" description = "Versioned process protocol and concurrent server for ShellCanvas connection adapters" diff --git a/crates/filesystem-sdk/Cargo.toml b/crates/filesystem-sdk/Cargo.toml index d5399bc..0472764 100644 --- a/crates/filesystem-sdk/Cargo.toml +++ b/crates/filesystem-sdk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shellcanvas-filesystem-sdk" -version = "0.1.8" +version = "0.1.9" edition = "2021" license = "MPL-2.0" description = "Optional filesystem handles and native bridge protocol for ShellCanvas" diff --git a/package-lock.json b/package-lock.json index 1f97461..ae7fd2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "shellcanvas", - "version": "0.1.8", + "version": "0.1.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "shellcanvas", - "version": "0.1.8", + "version": "0.1.9", "hasInstallScript": true, "license": "MPL-2.0", "workspaces": [ @@ -2556,7 +2556,7 @@ }, "packages/app-sdk": { "name": "@techartdev/shellcanvas-app-sdk", - "version": "0.1.8", + "version": "0.1.9", "license": "MPL-2.0", "dependencies": { "esbuild": "0.25.12" diff --git a/package.json b/package.json index 39cc2cc..5919b6e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "shellcanvas", - "version": "0.1.8", + "version": "0.1.9", "private": true, "type": "module", "license": "MPL-2.0", diff --git a/packages/app-sdk/package.json b/packages/app-sdk/package.json index 3a431ca..4ef9002 100644 --- a/packages/app-sdk/package.json +++ b/packages/app-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@techartdev/shellcanvas-app-sdk", - "version": "0.1.8", + "version": "0.1.9", "description": "Typed runtime app API and app packaging tools for ShellCanvas", "keywords": ["shellcanvas", "desktop", "remote", "sdk", "extensions"], "homepage": "https://shellcanvas.com/docs/app-sdk/quickstart.html", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 978dfbf..d86d558 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shellcanvas" -version = "0.1.8" +version = "0.1.9" edition = "2021" license = "MPL-2.0" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c80c642..0651532 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ShellCanvas", - "version": "0.1.8", + "version": "0.1.9", "identifier": "dev.shellcanvas.client", "build": { "beforeDevCommand": "npm run dev",