Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/adapter-sdk/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion crates/filesystem-sdk/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
75 changes: 75 additions & 0 deletions crates/ssh-core/examples/ssh_startup_probe.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
66 changes: 61 additions & 5 deletions crates/ssh-core/src/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand All @@ -36,6 +40,7 @@ pub struct VerifiedHost {
known_hosts: PathBuf,
additional_known_hosts: Option<PathBuf>,
approved_key: Option<keys::PublicKey>,
verified_key: Arc<OnceLock<keys::PublicKey>>,
}

pub fn verify_host_key(host: &str, port: u16, key: &keys::PublicKey, path: &Path) -> Result<()> {
Expand All @@ -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<VerifiedHost>,
host: String,
port: u16,
username: String,
host_key: keys::PublicKey,
known_hosts: PathBuf,
additional_known_hosts: Option<PathBuf>,
}

#[async_trait::async_trait]
Expand All @@ -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<Self> {
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<Self> {
let known_hosts = dirs::home_dir()
.context("Cannot locate your home directory")?
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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")?
Expand All @@ -215,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<String> {
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<Vec<u8>> {
timeout(OP_TIMEOUT, async {
let mut channel = self.handle.channel_open_session().await?;
channel.exec(true, command).await?;
Expand Down Expand Up @@ -245,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")?
Expand Down
Loading