Files
openchamber/packages/desktop/src-tauri/src/remote_ssh.rs
T
Bohdan Triapitsyn c38e00bd85 perf(desktop): reduce CPU/GPU overhead in Tauri shell
- SSH monitor: adaptive polling 2s→10s after stabilization, cheap TCP
  probe before expensive SSH subprocess check
- SSH setup: exponential backoff in wait_for_master_ready and
  wait_local_forward_ready (250ms→2s cap)
- Health checks: exponential backoff (100ms→1s / 250ms→2s) instead of
  flat intervals
- Startup recovery poll: cap at 15 retries instead of infinite
- Remove webview log target in release builds (eliminates IPC overhead)
- Set global NO_PROXY env var at startup for all loopback addresses
- Remove reqwest::blocking feature; use raw TCP for sidecar shutdown and
  SSH health checks
- Disable pinch-to-zoom on macOS via WKWebView.setAllowsMagnification
- Add WebView2 browser args on Windows (proxy bypass + disable unused
  UI features)
- Add Cargo release profile: thin LTO, codegen-units=1, strip
- Extract apply_platform_window_config for consistent window setup
- Add vibrancy toggle in Appearance settings (macOS desktop only) with
  solid background fallback when disabled
2026-04-01 11:54:35 +03:00

3003 lines
96 KiB
Rust

use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::{
collections::{HashMap, HashSet},
fs,
io::Read,
net::{TcpListener, TcpStream},
path::{Path, PathBuf},
process::{Child, Command, Stdio},
sync::{Arc, Mutex},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use tauri::{AppHandle, Emitter, State};
const LOCAL_HOST_ID: &str = "local";
const SSH_STATUS_EVENT: &str = "openchamber:ssh-instance-status";
const DEFAULT_CONNECTION_TIMEOUT_SEC: u16 = 60;
const DEFAULT_LOCAL_BIND_HOST: &str = "127.0.0.1";
const DEFAULT_CONTROL_PERSIST_SEC: u16 = 300;
const DEFAULT_READY_TIMEOUT_SEC: u64 = 30;
const DEFAULT_RECONNECT_MAX_ATTEMPTS: u32 = 5;
const MAX_LOG_LINES_PER_INSTANCE: usize = 1200;
/// Monitor starts with fast polling and relaxes to steady-state after stabilization.
const MONITOR_INITIAL_POLL_SECS: u64 = 2;
const MONITOR_STEADY_POLL_SECS: u64 = 10;
/// Number of healthy ticks before switching from initial to steady-state polling.
const MONITOR_STABILIZE_TICKS: u32 = 5;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DesktopSshInstancesConfig {
pub instances: Vec<DesktopSshInstance>,
}
impl Default for DesktopSshInstancesConfig {
fn default() -> Self {
Self {
instances: Vec::new(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DesktopSshParsedCommand {
pub destination: String,
pub args: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DesktopSshRemoteMode {
Managed,
External,
}
impl Default for DesktopSshRemoteMode {
fn default() -> Self {
Self::Managed
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DesktopSshInstallMethod {
Npm,
Bun,
DownloadRelease,
UploadBundle,
}
impl Default for DesktopSshInstallMethod {
fn default() -> Self {
Self::Bun
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DesktopSshRemoteOpenchamberConfig {
#[serde(default)]
pub mode: DesktopSshRemoteMode,
#[serde(default = "default_true")]
pub keep_running: bool,
pub preferred_port: Option<u16>,
#[serde(default)]
pub install_method: DesktopSshInstallMethod,
#[serde(default)]
pub upload_bundle_over_ssh: bool,
}
impl Default for DesktopSshRemoteOpenchamberConfig {
fn default() -> Self {
Self {
mode: DesktopSshRemoteMode::Managed,
keep_running: true,
preferred_port: None,
install_method: DesktopSshInstallMethod::Bun,
upload_bundle_over_ssh: false,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DesktopSshLocalForwardConfig {
pub preferred_local_port: Option<u16>,
#[serde(default = "default_local_bind_host")]
pub bind_host: String,
}
impl Default for DesktopSshLocalForwardConfig {
fn default() -> Self {
Self {
preferred_local_port: None,
bind_host: default_local_bind_host(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DesktopSshSecretStore {
Never,
Settings,
}
impl Default for DesktopSshSecretStore {
fn default() -> Self {
Self::Never
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DesktopSshStoredSecret {
#[serde(default)]
pub enabled: bool,
pub value: Option<String>,
#[serde(default)]
pub store: DesktopSshSecretStore,
}
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct DesktopSshAuthConfig {
pub ssh_password: Option<DesktopSshStoredSecret>,
pub openchamber_password: Option<DesktopSshStoredSecret>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DesktopSshPortForwardType {
Local,
Remote,
Dynamic,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DesktopSshPortForward {
pub id: String,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(rename = "type")]
pub forward_type: DesktopSshPortForwardType,
pub local_host: Option<String>,
pub local_port: Option<u16>,
pub remote_host: Option<String>,
pub remote_port: Option<u16>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DesktopSshInstance {
pub id: String,
pub nickname: Option<String>,
pub ssh_command: String,
pub ssh_parsed: Option<DesktopSshParsedCommand>,
#[serde(default = "default_connection_timeout")]
pub connection_timeout_sec: u16,
#[serde(default)]
pub remote_openchamber: DesktopSshRemoteOpenchamberConfig,
#[serde(default)]
pub local_forward: DesktopSshLocalForwardConfig,
#[serde(default)]
pub auth: DesktopSshAuthConfig,
#[serde(default)]
pub port_forwards: Vec<DesktopSshPortForward>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DesktopSshPhase {
Idle,
ConfigResolved,
AuthCheck,
MasterConnecting,
RemoteProbe,
Installing,
Updating,
ServerDetecting,
ServerStarting,
Forwarding,
Ready,
Degraded,
Error,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DesktopSshInstanceStatus {
pub id: String,
pub phase: DesktopSshPhase,
pub detail: Option<String>,
pub local_url: Option<String>,
pub local_port: Option<u16>,
pub remote_port: Option<u16>,
#[serde(default)]
pub started_by_us: bool,
#[serde(default)]
pub retry_attempt: u32,
#[serde(default)]
pub requires_user_action: bool,
pub updated_at_ms: u64,
}
impl DesktopSshInstanceStatus {
fn idle(id: impl Into<String>) -> Self {
Self {
id: id.into(),
phase: DesktopSshPhase::Idle,
detail: None,
local_url: None,
local_port: None,
remote_port: None,
started_by_us: false,
retry_attempt: 0,
requires_user_action: false,
updated_at_ms: now_millis(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DesktopSshImportCandidate {
pub host: String,
pub pattern: bool,
pub source: String,
pub ssh_command: String,
}
#[derive(Default)]
struct DesktopSshManagerInner {
statuses: Mutex<HashMap<String, DesktopSshInstanceStatus>>,
logs: Mutex<HashMap<String, Vec<String>>>,
sessions: Mutex<HashMap<String, SshSession>>,
connect_tasks: Mutex<HashMap<String, tauri::async_runtime::JoinHandle<()>>>,
monitor_tasks: Mutex<HashMap<String, tauri::async_runtime::JoinHandle<()>>>,
reconnect_attempts: Mutex<HashMap<String, u32>>,
connect_attempts: Mutex<HashMap<String, u32>>,
}
struct SshSession {
instance: DesktopSshInstance,
parsed: DesktopSshParsedCommand,
session_dir: PathBuf,
control_path: PathBuf,
local_port: u16,
remote_port: u16,
started_by_us: bool,
master: Child,
master_detached: bool,
main_forward: Child,
main_forward_detached: bool,
extra_forwards: Vec<Child>,
}
#[derive(Default)]
pub struct DesktopSshManagerState {
inner: Arc<DesktopSshManagerInner>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RemoteSystemInfo {
openchamber_version: Option<String>,
runtime: Option<String>,
pid: Option<u64>,
started_at: Option<String>,
}
fn default_true() -> bool {
true
}
fn default_connection_timeout() -> u16 {
DEFAULT_CONNECTION_TIMEOUT_SEC
}
fn default_local_bind_host() -> String {
DEFAULT_LOCAL_BIND_HOST.to_string()
}
fn now_millis() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
fn settings_file_path() -> PathBuf {
if let Ok(dir) = std::env::var("OPENCHAMBER_DATA_DIR") {
if !dir.trim().is_empty() {
return PathBuf::from(dir.trim()).join("settings.json");
}
}
let home = std::env::var("HOME").unwrap_or_default();
PathBuf::from(home)
.join(".config")
.join("openchamber")
.join("settings.json")
}
fn read_settings_root(path: &Path) -> Value {
let raw = fs::read_to_string(path).unwrap_or_default();
let parsed = serde_json::from_str::<Value>(&raw).unwrap_or_else(|_| json!({}));
if parsed.is_object() {
parsed
} else {
json!({})
}
}
fn write_settings_root(path: &Path, root: &Value) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, serde_json::to_string_pretty(root)?)?;
Ok(())
}
fn build_display_label(instance: &DesktopSshInstance) -> String {
if let Some(nick) = instance
.nickname
.as_ref()
.map(|v| v.trim())
.filter(|v| !v.is_empty())
{
return nick.to_string();
}
if let Some(parsed) = instance.ssh_parsed.as_ref() {
let destination = parsed.destination.trim();
if !destination.is_empty() {
return destination.to_string();
}
}
instance.id.clone()
}
fn read_desktop_ssh_instances_from_path(path: &Path) -> DesktopSshInstancesConfig {
let root = read_settings_root(path);
let Some(items) = root
.get("desktopSshInstances")
.and_then(Value::as_array)
.cloned()
else {
return DesktopSshInstancesConfig::default();
};
let mut instances = Vec::new();
let mut seen = HashSet::new();
for item in items {
let Ok(mut instance) = serde_json::from_value::<DesktopSshInstance>(item) else {
continue;
};
let id = instance.id.trim().to_string();
if id.is_empty() || id == LOCAL_HOST_ID || seen.contains(&id) {
continue;
}
instance.id = id.clone();
instance.connection_timeout_sec = if instance.connection_timeout_sec == 0 {
DEFAULT_CONNECTION_TIMEOUT_SEC
} else {
instance.connection_timeout_sec
};
if instance.local_forward.bind_host.trim().is_empty() {
instance.local_forward.bind_host = default_local_bind_host();
}
if instance.ssh_parsed.is_none() {
if let Ok(parsed) = parse_ssh_command(&instance.ssh_command) {
instance.ssh_parsed = Some(parsed);
}
}
seen.insert(id);
instances.push(instance);
}
DesktopSshInstancesConfig { instances }
}
fn read_desktop_ssh_instances_from_disk() -> DesktopSshInstancesConfig {
read_desktop_ssh_instances_from_path(&settings_file_path())
}
fn sanitize_bind_host(raw: &str) -> String {
let trimmed = raw.trim();
if trimmed.is_empty() {
return DEFAULT_LOCAL_BIND_HOST.to_string();
}
match trimmed {
"127.0.0.1" | "localhost" | "0.0.0.0" => trimmed.to_string(),
_ => DEFAULT_LOCAL_BIND_HOST.to_string(),
}
}
fn sanitize_forward(forward: &DesktopSshPortForward) -> Option<DesktopSshPortForward> {
let id = forward.id.trim().to_string();
if id.is_empty() {
return None;
}
let mut normalized = forward.clone();
normalized.id = id;
normalized.local_host = normalized
.local_host
.as_ref()
.map(|v| sanitize_bind_host(v))
.or_else(|| Some(DEFAULT_LOCAL_BIND_HOST.to_string()));
match normalized.forward_type {
DesktopSshPortForwardType::Local => {
if normalized.local_port.is_none() || normalized.remote_port.is_none() {
return None;
}
if normalized
.remote_host
.as_ref()
.map(|v| v.trim())
.unwrap_or("")
.is_empty()
{
normalized.remote_host = Some("127.0.0.1".to_string());
}
}
DesktopSshPortForwardType::Remote => {
if normalized.local_port.is_none() || normalized.remote_port.is_none() {
return None;
}
if normalized
.remote_host
.as_ref()
.map(|v| v.trim())
.unwrap_or("")
.is_empty()
{
normalized.remote_host = Some("127.0.0.1".to_string());
}
if normalized
.local_host
.as_ref()
.map(|v| v.trim())
.unwrap_or("")
.is_empty()
{
normalized.local_host = Some("127.0.0.1".to_string());
}
}
DesktopSshPortForwardType::Dynamic => {
if normalized.local_port.is_none() {
return None;
}
normalized.remote_host = None;
normalized.remote_port = None;
}
}
Some(normalized)
}
fn sanitize_instance(mut instance: DesktopSshInstance) -> Result<DesktopSshInstance> {
instance.id = instance.id.trim().to_string();
if instance.id.is_empty() || instance.id == LOCAL_HOST_ID {
return Err(anyhow!("SSH instance id is required"));
}
instance.ssh_command = instance.ssh_command.trim().to_string();
if instance.ssh_command.is_empty() {
return Err(anyhow!("SSH command is required"));
}
if instance.connection_timeout_sec == 0 {
instance.connection_timeout_sec = DEFAULT_CONNECTION_TIMEOUT_SEC;
}
instance.local_forward.bind_host = sanitize_bind_host(&instance.local_forward.bind_host);
let parsed = parse_ssh_command(&instance.ssh_command)?;
instance.ssh_parsed = Some(parsed);
let mut seen = HashSet::new();
let mut forwards = Vec::new();
for forward in &instance.port_forwards {
let Some(normalized) = sanitize_forward(forward) else {
continue;
};
if seen.contains(&normalized.id) {
continue;
}
seen.insert(normalized.id.clone());
forwards.push(normalized);
}
instance.port_forwards = forwards;
Ok(instance)
}
fn sync_desktop_hosts_for_ssh(
root: &mut Value,
previous_ids: &HashSet<String>,
instances: &[DesktopSshInstance],
) {
let next_ids: HashSet<String> = instances.iter().map(|item| item.id.clone()).collect();
let mut hosts = root
.get("desktopHosts")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
hosts.retain(|entry| {
let id = entry
.get("id")
.and_then(Value::as_str)
.map(|value| value.trim())
.unwrap_or("");
if id.is_empty() {
return false;
}
!(previous_ids.contains(id) && !next_ids.contains(id))
});
for instance in instances {
let label = build_display_label(instance);
let mut found = false;
for host in &mut hosts {
let host_id = host
.get("id")
.and_then(Value::as_str)
.map(|value| value.trim())
.unwrap_or("");
if host_id != instance.id {
continue;
}
if let Some(obj) = host.as_object_mut() {
obj.insert("id".to_string(), Value::String(instance.id.clone()));
obj.insert("label".to_string(), Value::String(label.clone()));
let should_set_default_url = obj
.get("url")
.and_then(Value::as_str)
.map(|value| value.trim().is_empty())
.unwrap_or(true);
if should_set_default_url {
obj.insert(
"url".to_string(),
Value::String("http://127.0.0.1/".to_string()),
);
}
}
found = true;
break;
}
if !found {
hosts.push(json!({
"id": instance.id,
"label": label,
"url": "http://127.0.0.1/"
}));
}
}
root["desktopHosts"] = Value::Array(hosts);
let default_id = root
.get("desktopDefaultHostId")
.and_then(Value::as_str)
.map(|value| value.trim().to_string())
.unwrap_or_default();
if !default_id.is_empty()
&& previous_ids.contains(default_id.as_str())
&& !next_ids.contains(default_id.as_str())
{
root["desktopDefaultHostId"] = Value::String(LOCAL_HOST_ID.to_string());
}
}
fn write_desktop_ssh_instances_to_path(
path: &Path,
config: DesktopSshInstancesConfig,
) -> Result<DesktopSshInstancesConfig> {
let mut root = read_settings_root(path);
let previous = read_desktop_ssh_instances_from_path(path);
let previous_ids: HashSet<String> = previous
.instances
.iter()
.map(|instance| instance.id.clone())
.collect();
let mut seen = HashSet::new();
let mut sanitized = Vec::new();
for instance in config.instances {
let normalized = sanitize_instance(instance)?;
if seen.contains(&normalized.id) {
continue;
}
seen.insert(normalized.id.clone());
sanitized.push(normalized);
}
sync_desktop_hosts_for_ssh(&mut root, &previous_ids, &sanitized);
root["desktopSshInstances"] = serde_json::to_value(&sanitized)?;
write_settings_root(path, &root)?;
Ok(DesktopSshInstancesConfig {
instances: sanitized,
})
}
fn update_ssh_host_url(instance_id: &str, label: &str, local_url: &str) -> Result<()> {
let path = settings_file_path();
let mut root = read_settings_root(&path);
let mut hosts = root
.get("desktopHosts")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let mut found = false;
for host in &mut hosts {
let host_id = host
.get("id")
.and_then(Value::as_str)
.map(|value| value.trim())
.unwrap_or("");
if host_id != instance_id {
continue;
}
if let Some(obj) = host.as_object_mut() {
obj.insert("id".to_string(), Value::String(instance_id.to_string()));
obj.insert("label".to_string(), Value::String(label.to_string()));
obj.insert("url".to_string(), Value::String(local_url.to_string()));
found = true;
break;
}
}
if !found {
hosts.push(json!({
"id": instance_id,
"label": label,
"url": local_url
}));
}
root["desktopHosts"] = Value::Array(hosts);
write_settings_root(&path, &root)
}
fn persist_local_port_for_instance(instance_id: &str, local_port: u16) -> Result<()> {
let path = settings_file_path();
let mut root = read_settings_root(&path);
let mut changed = false;
if let Some(items) = root
.get_mut("desktopSshInstances")
.and_then(Value::as_array_mut)
{
for item in items {
let Some(id) = item.get("id").and_then(Value::as_str) else {
continue;
};
if id.trim() != instance_id {
continue;
}
if item
.get("localForward")
.and_then(Value::as_object)
.is_none()
{
item["localForward"] = json!({});
}
item["localForward"]["preferredLocalPort"] = Value::Number(local_port.into());
changed = true;
break;
}
}
if changed {
write_settings_root(&path, &root)?;
}
Ok(())
}
fn split_shell_words(input: &str) -> Result<Vec<String>> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut chars = input.chars().peekable();
let mut in_single = false;
let mut in_double = false;
while let Some(ch) = chars.next() {
match ch {
'\\' if !in_single => {
if let Some(next) = chars.next() {
current.push(next);
}
}
'\'' if !in_double => {
in_single = !in_single;
}
'"' if !in_single => {
in_double = !in_double;
}
c if c.is_whitespace() && !in_single && !in_double => {
if !current.is_empty() {
tokens.push(std::mem::take(&mut current));
}
}
_ => current.push(ch),
}
}
if in_single || in_double {
return Err(anyhow!("Unclosed quote in SSH command"));
}
if !current.is_empty() {
tokens.push(current);
}
Ok(tokens)
}
fn is_disallowed_primary_flag(token: &str) -> bool {
const DISALLOWED: [&str; 17] = [
"-M", "-S", "-O", "-N", "-t", "-T", "-f", "-G", "-W", "-v", "-V", "-q", "-n", "-s", "-e",
"-E", "-g",
];
DISALLOWED.contains(&token)
}
fn has_disallowed_o_option(value: &str) -> bool {
let lower = value.trim().to_ascii_lowercase();
[
"controlmaster",
"controlpath",
"controlpersist",
"batchmode",
"proxycommand",
]
.iter()
.any(|prefix| lower.starts_with(prefix))
}
fn parse_ssh_command(raw: &str) -> Result<DesktopSshParsedCommand> {
let mut tokens = split_shell_words(raw)?;
if tokens.is_empty() {
return Err(anyhow!("SSH command is empty"));
}
if tokens[0] == "ssh" {
tokens.remove(0);
}
if tokens.is_empty() {
return Err(anyhow!("SSH command must include destination"));
}
const ALLOWED_FLAGS: [&str; 11] = [
"-4", "-6", "-A", "-a", "-C", "-K", "-k", "-X", "-x", "-Y", "-y",
];
const ALLOWED_WITH_VALUES: [&str; 14] = [
"-B", "-b", "-c", "-D", "-F", "-I", "-i", "-J", "-l", "-m", "-o", "-P", "-p", "-R",
];
let mut destination: Option<String> = None;
let mut args = Vec::new();
let mut idx = 0usize;
while idx < tokens.len() {
let token = tokens[idx].clone();
if destination.is_some() {
return Err(anyhow!(
"SSH command has unsupported trailing argument: {token}"
));
}
if token.starts_with('-') {
if is_disallowed_primary_flag(token.as_str()) {
return Err(anyhow!("SSH option {token} is not allowed"));
}
if ALLOWED_FLAGS.contains(&token.as_str()) {
args.push(token);
idx += 1;
continue;
}
let mut matched = false;
for option in ALLOWED_WITH_VALUES {
if token == option {
if idx + 1 >= tokens.len() {
return Err(anyhow!("SSH option {option} requires a value"));
}
let value = tokens[idx + 1].clone();
if option == "-o" && has_disallowed_o_option(&value) {
return Err(anyhow!("SSH option -o {value} is not allowed"));
}
args.push(token.clone());
args.push(value);
idx += 2;
matched = true;
break;
}
if token.starts_with(option) && token.len() > option.len() {
let value = token[option.len()..].to_string();
if option == "-o" && has_disallowed_o_option(&value) {
return Err(anyhow!("SSH option -o {value} is not allowed"));
}
args.push(token.clone());
idx += 1;
matched = true;
break;
}
}
if !matched {
return Err(anyhow!("Unsupported SSH option: {token}"));
}
continue;
}
destination = Some(token);
idx += 1;
}
let Some(destination) = destination
.map(|d| d.trim().to_string())
.filter(|d| !d.is_empty())
else {
return Err(anyhow!("SSH command must include destination"));
};
Ok(DesktopSshParsedCommand { destination, args })
}
fn shell_quote(value: &str) -> String {
let escaped = value.replace('\'', "'\\''");
format!("'{escaped}'")
}
fn run_output(command: &mut Command) -> Result<(i32, String, String)> {
let output = command
.output()
.with_context(|| format!("failed to execute command: {:?}", command))?;
let code = output.status.code().unwrap_or(-1);
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Ok((code, stdout, stderr))
}
fn build_ssh_command(
parsed: &DesktopSshParsedCommand,
pre_destination_args: &[String],
remote_command: Option<&str>,
) -> Command {
let mut command = Command::new("ssh");
command
.args(&parsed.args)
.args(pre_destination_args)
.arg(&parsed.destination);
if let Some(remote) = remote_command {
command.arg(remote);
}
command
}
fn resolve_ssh_config(parsed: &DesktopSshParsedCommand) -> Result<HashMap<String, String>> {
let args = vec!["-G".to_string()];
let mut command = build_ssh_command(parsed, &args, None);
let (code, stdout, stderr) = run_output(&mut command)?;
if code != 0 {
return Err(anyhow!(stderr.trim().to_string()));
}
let mut resolved = HashMap::new();
for line in stdout.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let mut parts = trimmed.splitn(2, ' ');
let key = parts.next().unwrap_or_default().trim().to_ascii_lowercase();
let value = parts.next().unwrap_or_default().trim();
if key.is_empty() || value.is_empty() {
continue;
}
resolved.insert(key, value.to_string());
}
Ok(resolved)
}
fn ensure_session_dir(instance_id: &str) -> Result<PathBuf> {
let base = settings_file_path()
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("ssh")
.join(instance_id);
fs::create_dir_all(&base)?;
Ok(base)
}
fn control_path_for_instance(_session_dir: &Path, instance_id: &str) -> PathBuf {
let hash = {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
instance_id.hash(&mut hasher);
hasher.finish()
};
std::env::temp_dir().join(format!("ocssh-{hash:x}.sock"))
}
fn askpass_script_content() -> String {
let script = r#"#!/bin/bash
PROMPT="$1"
if [[ -n "$OPENCHAMBER_SSH_ASKPASS_VALUE" ]]; then
if [[ "$PROMPT" == *"assword"* || "$PROMPT" == *"passphrase"* ]]; then
printf '%s\n' "$OPENCHAMBER_SSH_ASKPASS_VALUE"
exit 0
fi
fi
DEFAULT_ANSWER=""
HIDDEN_INPUT="true"
if [[ "$PROMPT" == *"yes/no"* ]]; then
DEFAULT_ANSWER="yes"
HIDDEN_INPUT="false"
fi
/usr/bin/osascript <<'APPLESCRIPT' "$PROMPT" "$DEFAULT_ANSWER" "$HIDDEN_INPUT"
on run argv
set promptText to item 1 of argv
set defaultAnswer to item 2 of argv
set hiddenInput to item 3 of argv
try
if hiddenInput is "true" then
set response to display dialog promptText default answer defaultAnswer with hidden answer buttons {"Cancel", "OK"} default button "OK"
else
set response to display dialog promptText default answer defaultAnswer buttons {"Cancel", "OK"} default button "OK"
end if
return text returned of response
on error
error number -128
end try
end run
APPLESCRIPT
"#;
script.to_string()
}
fn write_askpass_script(path: &Path) -> Result<()> {
fs::write(path, askpass_script_content())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perm = fs::metadata(path)?.permissions();
perm.set_mode(0o700);
fs::set_permissions(path, perm)?;
}
Ok(())
}
fn spawn_master_process(
parsed: &DesktopSshParsedCommand,
control_path: &Path,
askpass_path: &Path,
ssh_password: Option<&str>,
) -> Result<Child> {
let args = vec![
"-o".to_string(),
"ControlMaster=yes".to_string(),
"-o".to_string(),
format!("ControlPath={}", control_path.display()),
"-o".to_string(),
format!("ControlPersist={DEFAULT_CONTROL_PERSIST_SEC}"),
"-N".to_string(),
];
let mut command = build_ssh_command(parsed, &args, None);
command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env("SSH_ASKPASS_REQUIRE", "force")
.env("SSH_ASKPASS", askpass_path)
.env("DISPLAY", "1");
if let Some(secret) = ssh_password.filter(|value| !value.trim().is_empty()) {
command.env("OPENCHAMBER_SSH_ASKPASS_VALUE", secret.trim());
}
command.spawn().with_context(|| {
format!(
"failed to start SSH ControlMaster for {}",
parsed.destination
)
})
}
fn wait_for_master_ready(
parsed: &DesktopSshParsedCommand,
control_path: &Path,
timeout_sec: u16,
master: &mut Child,
) -> Result<()> {
let deadline = std::time::Instant::now() + Duration::from_secs(timeout_sec as u64);
let mut poll_ms: u64 = 250;
while std::time::Instant::now() < deadline {
let args = vec![
"-o".to_string(),
"ControlMaster=no".to_string(),
"-o".to_string(),
format!("ControlPath={}", control_path.display()),
"-O".to_string(),
"check".to_string(),
];
let mut check = build_ssh_command(parsed, &args, None);
let (code, _stdout, _stderr) = run_output(&mut check)?;
if code == 0 {
return Ok(());
}
if let Some(status) = master.try_wait().ok().flatten() {
let mut stderr = String::new();
if let Some(mut stream) = master.stderr.take() {
let _ = stream.read_to_string(&mut stderr);
}
if stderr.trim().is_empty() {
return Err(anyhow!(format!(
"SSH master process exited before ready (status: {status})"
)));
}
return Err(anyhow!(stderr.trim().to_string()));
}
std::thread::sleep(Duration::from_millis(poll_ms));
poll_ms = (poll_ms * 2).min(2000);
}
Err(anyhow!("SSH ControlMaster connection timed out"))
}
fn control_master_operation(
parsed: &DesktopSshParsedCommand,
control_path: &Path,
op: &str,
) -> Result<(i32, String, String)> {
let args = vec![
"-o".to_string(),
"ControlMaster=no".to_string(),
"-o".to_string(),
format!("ControlPath={}", control_path.display()),
"-o".to_string(),
"BatchMode=yes".to_string(),
"-o".to_string(),
"ConnectTimeout=3".to_string(),
"-O".to_string(),
op.to_string(),
];
let mut command = build_ssh_command(parsed, &args, None);
run_output(&mut command)
}
fn is_control_master_alive(parsed: &DesktopSshParsedCommand, control_path: &Path) -> bool {
control_master_operation(parsed, control_path, "check")
.map(|(code, _, _)| code == 0)
.unwrap_or(false)
}
fn stop_control_master_best_effort(parsed: &DesktopSshParsedCommand, control_path: &Path) {
let _ = control_master_operation(parsed, control_path, "exit");
}
fn run_remote_command(
parsed: &DesktopSshParsedCommand,
control_path: &Path,
script: &str,
timeout_sec: u16,
) -> Result<String> {
let args = vec![
"-o".to_string(),
"ControlMaster=no".to_string(),
"-o".to_string(),
format!("ControlPath={}", control_path.display()),
"-o".to_string(),
format!("ConnectTimeout={timeout_sec}"),
"-T".to_string(),
];
let remote = format!("sh -lc {}", shell_quote(script));
let mut command = build_ssh_command(parsed, &args, Some(&remote));
let (code, stdout, stderr) = run_output(&mut command)?;
if code != 0 {
if stderr.trim().is_empty() {
return Err(anyhow!("Remote command failed"));
}
return Err(anyhow!(stderr.trim().to_string()));
}
Ok(stdout)
}
fn remote_command_exists(
parsed: &DesktopSshParsedCommand,
control_path: &Path,
command_name: &str,
) -> bool {
run_remote_command(
parsed,
control_path,
&format!(
"command -v {} >/dev/null 2>&1 && echo yes || echo no",
command_name
),
DEFAULT_CONNECTION_TIMEOUT_SEC,
)
.map(|output| output.trim() == "yes")
.unwrap_or(false)
}
fn parse_version_token(raw: &str) -> Option<String> {
for token in raw.split_whitespace() {
let mut candidate = token.trim().trim_start_matches('v').to_string();
while candidate.ends_with(',') || candidate.ends_with(')') || candidate.ends_with('(') {
candidate.pop();
}
let parts: Vec<&str> = candidate.split('.').collect();
if parts.len() < 2 {
continue;
}
if parts
.iter()
.all(|part| !part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit()))
{
return Some(candidate);
}
}
None
}
fn current_remote_openchamber_version(
parsed: &DesktopSshParsedCommand,
control_path: &Path,
) -> Option<String> {
run_remote_command(
parsed,
control_path,
"openchamber --version 2>/dev/null || true",
DEFAULT_CONNECTION_TIMEOUT_SEC,
)
.ok()
.and_then(|value| parse_version_token(&value))
}
fn install_openchamber_managed(
parsed: &DesktopSshParsedCommand,
control_path: &Path,
version: &str,
preferred: &DesktopSshInstallMethod,
) -> Result<()> {
let has_bun = remote_command_exists(parsed, control_path, "bun");
let has_npm = remote_command_exists(parsed, control_path, "npm");
let mut commands = Vec::new();
match preferred {
DesktopSshInstallMethod::Bun => {
if has_bun {
commands.push(format!("bun add -g @openchamber/web@{version}"));
}
if has_npm {
commands.push(format!("npm install -g @openchamber/web@{version}"));
}
}
DesktopSshInstallMethod::Npm => {
if has_npm {
commands.push(format!("npm install -g @openchamber/web@{version}"));
}
if has_bun {
commands.push(format!("bun add -g @openchamber/web@{version}"));
}
}
DesktopSshInstallMethod::DownloadRelease | DesktopSshInstallMethod::UploadBundle => {
if has_bun {
commands.push(format!("bun add -g @openchamber/web@{version}"));
}
if has_npm {
commands.push(format!("npm install -g @openchamber/web@{version}"));
}
}
}
if commands.is_empty() {
return Err(anyhow!("Remote host has neither bun nor npm available"));
}
let mut last_error: Option<anyhow::Error> = None;
for command in commands {
match run_remote_command(
parsed,
control_path,
&command,
DEFAULT_CONNECTION_TIMEOUT_SEC,
) {
Ok(_) => return Ok(()),
Err(err) => {
last_error = Some(err);
}
}
}
Err(last_error.unwrap_or_else(|| anyhow!("Failed to install OpenChamber on remote host")))
}
fn parse_probe_status_line(line: Option<&str>, prefix: &str) -> Option<u16> {
let value = line?.strip_prefix(prefix)?.trim();
value.parse::<u16>().ok()
}
fn is_auth_http_status(status: u16) -> bool {
status == 401 || status == 403
}
fn is_liveness_http_status(status: u16) -> bool {
(200..=299).contains(&status) || is_auth_http_status(status)
}
fn configured_openchamber_password(instance: &DesktopSshInstance) -> Option<&str> {
instance
.auth
.openchamber_password
.as_ref()
.and_then(|secret| {
if secret.enabled {
secret.value.as_deref()
} else {
None
}
})
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn probe_remote_system_info(
parsed: &DesktopSshParsedCommand,
control_path: &Path,
port: u16,
openchamber_password: Option<&str>,
) -> Result<RemoteSystemInfo> {
let auth_payload = if let Some(password) = openchamber_password {
serde_json::to_string(&json!({ "password": password })).unwrap_or_else(|_| "{}".to_string())
} else {
"{}".to_string()
};
let auth_enabled = if openchamber_password.is_some() {
"1"
} else {
"0"
};
let script = format!(
"AUTH_STATUS=0; INFO_STATUS=0; HEALTH_STATUS=0; BODY_FILE=\"$(mktemp)\"; COOKIE_FILE=\"$(mktemp)\"; cleanup() {{ rm -f \"$BODY_FILE\" \"$COOKIE_FILE\"; }}; trap cleanup EXIT; if command -v curl >/dev/null 2>&1; then if [ \"{auth_enabled}\" = \"1\" ]; then AUTH_STATUS=\"$(curl -sS --max-time 3 -o /dev/null -w '%{{http_code}}' -c \"$COOKIE_FILE\" -H 'content-type: application/json' --data {auth_payload} http://127.0.0.1:{port}/auth/session || true)\"; if [ \"$AUTH_STATUS\" = \"200\" ]; then INFO_STATUS=\"$(curl -sS --max-time 3 -b \"$COOKIE_FILE\" -o \"$BODY_FILE\" -w '%{{http_code}}' http://127.0.0.1:{port}/api/system/info || true)\"; else INFO_STATUS=\"$(curl -sS --max-time 3 -o \"$BODY_FILE\" -w '%{{http_code}}' http://127.0.0.1:{port}/api/system/info || true)\"; fi; else INFO_STATUS=\"$(curl -sS --max-time 3 -o \"$BODY_FILE\" -w '%{{http_code}}' http://127.0.0.1:{port}/api/system/info || true)\"; fi; HEALTH_STATUS=\"$(curl -sS --max-time 3 -o /dev/null -w '%{{http_code}}' http://127.0.0.1:{port}/health || true)\"; elif command -v wget >/dev/null 2>&1; then wget -qO \"$BODY_FILE\" http://127.0.0.1:{port}/api/system/info >/dev/null 2>&1; if [ $? -eq 0 ]; then INFO_STATUS=200; fi; wget -qO- http://127.0.0.1:{port}/health >/dev/null 2>&1; if [ $? -eq 0 ]; then HEALTH_STATUS=200; fi; else exit 127; fi; printf 'INFO_STATUS=%s\\nAUTH_STATUS=%s\\nHEALTH_STATUS=%s\\n' \"$INFO_STATUS\" \"$AUTH_STATUS\" \"$HEALTH_STATUS\"; cat \"$BODY_FILE\" 2>/dev/null || true",
auth_payload = shell_quote(&auth_payload),
);
let output = run_remote_command(
parsed,
control_path,
&script,
DEFAULT_CONNECTION_TIMEOUT_SEC,
)?;
let mut lines = output.lines();
let info_status = parse_probe_status_line(lines.next(), "INFO_STATUS=").unwrap_or(0);
let auth_status = parse_probe_status_line(lines.next(), "AUTH_STATUS=").unwrap_or(0);
let health_status = parse_probe_status_line(lines.next(), "HEALTH_STATUS=").unwrap_or(0);
let body = lines.collect::<Vec<&str>>().join("\n");
if is_liveness_http_status(info_status) {
if is_auth_http_status(info_status) {
if openchamber_password.is_some() && auth_status != 200 {
return Err(anyhow!(format!(
"Remote OpenChamber requires UI authentication and configured password was rejected (auth status {auth_status})"
)));
}
if is_liveness_http_status(health_status) {
return Ok(RemoteSystemInfo::default());
}
return Err(anyhow!(
"Remote OpenChamber requires UI authentication on /api/system/info; configure OpenChamber UI password"
));
}
} else if is_liveness_http_status(health_status) {
return Ok(RemoteSystemInfo::default());
} else {
return Err(anyhow!(format!(
"Remote OpenChamber probe failed (info status {info_status}, health status {health_status})"
)));
}
let mut info = serde_json::from_str::<RemoteSystemInfo>(&body).unwrap_or_default();
if info.openchamber_version.is_none() {
if let Ok(value) = serde_json::from_str::<Value>(&body) {
info.openchamber_version = value
.get("openchamberVersion")
.and_then(Value::as_str)
.map(|v| v.to_string());
info.runtime = value
.get("runtime")
.and_then(Value::as_str)
.map(|v| v.to_string());
info.pid = value.get("pid").and_then(Value::as_u64);
info.started_at = value
.get("startedAt")
.and_then(Value::as_str)
.map(|v| v.to_string());
}
}
Ok(info)
}
fn remote_server_running(
parsed: &DesktopSshParsedCommand,
control_path: &Path,
port: u16,
openchamber_password: Option<&str>,
) -> bool {
probe_remote_system_info(parsed, control_path, port, openchamber_password).is_ok()
}
fn random_port_candidate(seed: &str) -> u16 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
seed.hash(&mut hasher);
now_millis().hash(&mut hasher);
let value = hasher.finish();
let base = 20_000u16;
let span = 30_000u16;
base + ((value % span as u64) as u16)
}
fn start_remote_server_managed(
parsed: &DesktopSshParsedCommand,
control_path: &Path,
instance: &DesktopSshInstance,
desired_port: u16,
) -> Result<u16> {
let mut env_prefix = "OPENCHAMBER_RUNTIME=ssh-remote".to_string();
if let Some(secret) = instance
.auth
.openchamber_password
.as_ref()
.and_then(|v| if v.enabled { v.value.clone() } else { None })
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
{
env_prefix.push(' ');
env_prefix.push_str("OPENCHAMBER_UI_PASSWORD=");
env_prefix.push_str(&shell_quote(&secret));
}
let script = format!(
"{env_prefix} openchamber serve --daemon --hostname 127.0.0.1 --port {desired_port}"
);
let output = run_remote_command(
parsed,
control_path,
&script,
DEFAULT_CONNECTION_TIMEOUT_SEC,
)?;
if let Some(port) = output
.split_whitespace()
.find_map(|token| token.parse::<u16>().ok())
{
return Ok(port);
}
Ok(desired_port)
}
fn stop_remote_server_best_effort(
parsed: &DesktopSshParsedCommand,
control_path: &Path,
remote_port: u16,
) {
let script = format!(
"if command -v curl >/dev/null 2>&1; then curl -fsS -X POST http://127.0.0.1:{remote_port}/api/system/shutdown >/dev/null 2>&1 || true; elif command -v wget >/dev/null 2>&1; then wget -qO- --method=POST http://127.0.0.1:{remote_port}/api/system/shutdown >/dev/null 2>&1 || true; fi"
);
let _ = run_remote_command(
parsed,
control_path,
&script,
DEFAULT_CONNECTION_TIMEOUT_SEC,
);
}
fn spawn_main_forward(
parsed: &DesktopSshParsedCommand,
control_path: &Path,
bind_host: &str,
local_port: u16,
remote_port: u16,
) -> Result<Child> {
let args = vec![
"-o".to_string(),
"ControlMaster=no".to_string(),
"-o".to_string(),
format!("ControlPath={}", control_path.display()),
"-N".to_string(),
"-L".to_string(),
format!("{bind_host}:{local_port}:127.0.0.1:{remote_port}"),
];
let mut command = build_ssh_command(parsed, &args, None);
command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.with_context(|| format!("Failed to start main SSH forward on local port {local_port}"))
}
fn spawn_extra_forward(
parsed: &DesktopSshParsedCommand,
control_path: &Path,
forward: &DesktopSshPortForward,
) -> Result<()> {
let mut args = vec![
"-o".to_string(),
"ControlMaster=no".to_string(),
"-o".to_string(),
format!("ControlPath={}", control_path.display()),
"-O".to_string(),
"forward".to_string(),
];
match forward.forward_type {
DesktopSshPortForwardType::Local => {
let local_host = forward
.local_host
.as_deref()
.filter(|v| !v.trim().is_empty())
.unwrap_or("127.0.0.1");
let local_port = forward
.local_port
.ok_or_else(|| anyhow!("Missing local port"))?;
let remote_host = forward
.remote_host
.as_deref()
.filter(|v| !v.trim().is_empty())
.unwrap_or("127.0.0.1");
let remote_port = forward
.remote_port
.ok_or_else(|| anyhow!("Missing remote port"))?;
args.push("-L".to_string());
args.push(format!(
"{local_host}:{local_port}:{remote_host}:{remote_port}"
));
}
DesktopSshPortForwardType::Remote => {
let remote_host = forward
.remote_host
.as_deref()
.filter(|v| !v.trim().is_empty())
.unwrap_or("127.0.0.1");
let remote_port = forward
.remote_port
.ok_or_else(|| anyhow!("Missing remote port"))?;
let local_host = forward
.local_host
.as_deref()
.filter(|v| !v.trim().is_empty())
.unwrap_or("127.0.0.1");
let local_port = forward
.local_port
.ok_or_else(|| anyhow!("Missing local port"))?;
args.push("-R".to_string());
args.push(format!(
"{remote_host}:{remote_port}:{local_host}:{local_port}"
));
}
DesktopSshPortForwardType::Dynamic => {
let local_host = forward
.local_host
.as_deref()
.filter(|v| !v.trim().is_empty())
.unwrap_or("127.0.0.1");
let local_port = forward
.local_port
.ok_or_else(|| anyhow!("Missing local port"))?;
args.push("-D".to_string());
args.push(format!("{local_host}:{local_port}"));
}
}
let mut command = build_ssh_command(parsed, &args, None);
let (code, stdout, stderr) = run_output(&mut command)
.with_context(|| format!("Failed to configure extra SSH forward {}", forward.id))?;
if code != 0 {
let detail = if stderr.trim().is_empty() {
stdout.trim()
} else {
stderr.trim()
};
return Err(anyhow!(format!(
"Failed to configure extra SSH forward {}: {}",
forward.id,
if detail.is_empty() {
"unknown error"
} else {
detail
}
)));
}
Ok(())
}
fn is_local_port_available(bind_host: &str, port: u16) -> bool {
TcpListener::bind(format!("{bind_host}:{port}")).is_ok()
}
fn pick_unused_local_port() -> Result<u16> {
let listener = TcpListener::bind("127.0.0.1:0")?;
Ok(listener.local_addr()?.port())
}
fn is_local_tunnel_reachable(local_port: u16) -> bool {
let addr = format!("127.0.0.1:{local_port}");
let Ok(parsed) = addr.parse() else {
return false;
};
TcpStream::connect_timeout(&parsed, Duration::from_millis(500)).is_ok()
}
fn wait_local_forward_ready(local_port: u16) -> Result<()> {
let deadline = std::time::Instant::now() + Duration::from_secs(DEFAULT_READY_TIMEOUT_SEC);
let addr: std::net::SocketAddr = format!("127.0.0.1:{local_port}").parse()?;
let mut poll_ms: u64 = 250;
while std::time::Instant::now() < deadline {
if let Ok(mut stream) =
TcpStream::connect_timeout(&addr, Duration::from_millis(1000))
{
use std::io::{Read as IoRead, Write};
let _ = stream.set_read_timeout(Some(Duration::from_millis(1000)));
let _ = stream.set_write_timeout(Some(Duration::from_millis(1000)));
let request = format!(
"GET /health HTTP/1.1\r\nHost: 127.0.0.1:{local_port}\r\nConnection: close\r\n\r\n"
);
if stream.write_all(request.as_bytes()).is_ok() {
let mut buf = [0u8; 32];
if let Ok(n) = stream.read(&mut buf) {
let head = std::str::from_utf8(&buf[..n]).unwrap_or("");
// Match "HTTP/1.x 2xx" or "HTTP/1.x 401"
if head.starts_with("HTTP/1.")
&& (head.contains(" 2") || head.contains(" 401"))
{
return Ok(());
}
}
}
}
std::thread::sleep(Duration::from_millis(poll_ms));
poll_ms = (poll_ms * 2).min(2000);
}
Err(anyhow!(
"Timed out waiting for forwarded OpenChamber health"
))
}
fn kill_child(child: &mut Child) {
let _ = child.kill();
let _ = child.wait();
}
fn parse_ssh_config_candidates(path: &Path, source: &str) -> Vec<DesktopSshImportCandidate> {
let Ok(content) = fs::read_to_string(path) else {
return Vec::new();
};
let mut candidates = Vec::new();
for line in content.lines() {
let trimmed = line.split('#').next().map(|part| part.trim()).unwrap_or("");
if trimmed.is_empty() {
continue;
}
if trimmed.len() < 4 {
continue;
}
if !trimmed[..4].eq_ignore_ascii_case("host") {
continue;
}
let rest = trimmed[4..].trim();
if rest.is_empty() {
continue;
}
for token in rest.split_whitespace() {
let host = token.trim();
if host.is_empty() || host.starts_with('!') {
continue;
}
if host == "*" {
continue;
}
let pattern = host.contains('*') || host.contains('?');
candidates.push(DesktopSshImportCandidate {
host: host.to_string(),
pattern,
source: source.to_string(),
ssh_command: format!("ssh {host}"),
});
}
}
candidates
}
impl DesktopSshManagerInner {
fn append_log_with_level(&self, id: &str, level: &str, message: impl Into<String>) {
let line = format!("[{}] [{}] {}", now_millis(), level, message.into());
let mut logs = self.logs.lock().expect("ssh logs mutex");
let entry = logs.entry(id.to_string()).or_default();
entry.push(line);
if entry.len() > MAX_LOG_LINES_PER_INSTANCE {
let overflow = entry.len() - MAX_LOG_LINES_PER_INSTANCE;
entry.drain(0..overflow);
}
}
fn append_log(&self, id: &str, message: impl Into<String>) {
self.append_log_with_level(id, "INFO", message);
}
fn append_attempt_separator(&self, id: &str, connect_attempt: u32, retry_attempt: u32) {
let scope = if retry_attempt > 0 {
format!("retry {retry_attempt}")
} else {
"manual".to_string()
};
self.append_log_with_level(
id,
"INFO",
format!("---------------- attempt #{connect_attempt} ({scope}) ----------------"),
);
}
fn logs_for_instance(&self, id: &str, limit: usize) -> Vec<String> {
let logs = self.logs.lock().expect("ssh logs mutex");
let mut lines = logs.get(id).cloned().unwrap_or_default();
if limit > 0 && lines.len() > limit {
let keep_from = lines.len() - limit;
lines.drain(0..keep_from);
}
lines
}
fn clear_logs_for_instance(&self, id: &str) {
self.logs.lock().expect("ssh logs mutex").remove(id);
}
fn status_snapshot_for_instance(&self, id: &str) -> DesktopSshInstanceStatus {
self.statuses
.lock()
.expect("ssh status mutex")
.get(id)
.cloned()
.unwrap_or_else(|| DesktopSshInstanceStatus::idle(id))
}
fn set_status(
&self,
app: &AppHandle,
id: &str,
phase: DesktopSshPhase,
detail: Option<String>,
local_url: Option<String>,
local_port: Option<u16>,
remote_port: Option<u16>,
started_by_us: bool,
retry_attempt: u32,
requires_user_action: bool,
) {
let level = if matches!(&phase, DesktopSshPhase::Error) {
"ERROR"
} else if matches!(&phase, DesktopSshPhase::Degraded) {
"WARN"
} else {
"INFO"
};
self.append_log_with_level(
id,
level,
format!(
"phase={} detail={} retry={} requires_user_action={}",
serde_json::to_string(&phase).unwrap_or_else(|_| "\"unknown\"".to_string()),
detail.as_deref().unwrap_or(""),
retry_attempt,
requires_user_action
),
);
let status = DesktopSshInstanceStatus {
id: id.to_string(),
phase,
detail,
local_url,
local_port,
remote_port,
started_by_us,
retry_attempt,
requires_user_action,
updated_at_ms: now_millis(),
};
self.statuses
.lock()
.expect("ssh status mutex")
.insert(id.to_string(), status.clone());
let _ = app.emit(SSH_STATUS_EVENT, status);
}
fn clear_retry_attempt(&self, id: &str) {
self.reconnect_attempts
.lock()
.expect("ssh retry mutex")
.remove(id);
}
fn next_retry_attempt(&self, id: &str) -> u32 {
let mut guard = self.reconnect_attempts.lock().expect("ssh retry mutex");
let next = guard.get(id).copied().unwrap_or(0).saturating_add(1);
guard.insert(id.to_string(), next);
next
}
fn current_retry_attempt(&self, id: &str) -> u32 {
self.reconnect_attempts
.lock()
.expect("ssh retry mutex")
.get(id)
.copied()
.unwrap_or(0)
}
fn next_connect_attempt(&self, id: &str) -> u32 {
let mut guard = self
.connect_attempts
.lock()
.expect("ssh connect-attempt mutex");
let next = guard.get(id).copied().unwrap_or(0).saturating_add(1);
guard.insert(id.to_string(), next);
next
}
fn cancel_connect_task(&self, id: &str) {
if let Some(handle) = self
.connect_tasks
.lock()
.expect("ssh connect task mutex")
.remove(id)
{
handle.abort();
}
}
fn cancel_monitor_task(&self, id: &str) {
if let Some(handle) = self
.monitor_tasks
.lock()
.expect("ssh monitor task mutex")
.remove(id)
{
handle.abort();
}
}
fn session_is_alive(&self, id: &str) -> bool {
let mut sessions = self.sessions.lock().expect("ssh sessions mutex");
let Some(session) = sessions.get_mut(id) else {
return false;
};
let mut main_anchor_alive = false;
if !session.main_forward_detached {
if let Some(status) = session.main_forward.try_wait().ok().flatten() {
if status.success() {
session.main_forward_detached = true;
self.append_log_with_level(
id,
"INFO",
"Main tunnel helper exited after ControlMaster handoff",
);
} else {
let mut stderr = String::new();
if let Some(mut stream) = session.main_forward.stderr.take() {
let _ = stream.read_to_string(&mut stderr);
}
self.append_log_with_level(
id,
"WARN",
if stderr.trim().is_empty() {
format!("Existing main SSH forward is not running ({status})")
} else {
format!(
"Existing main SSH forward is not running ({status}): {}",
stderr.trim()
)
},
);
return false;
}
} else {
main_anchor_alive = true;
}
}
if main_anchor_alive {
return true;
}
if session.master_detached {
if !is_control_master_alive(&session.parsed, &session.control_path) {
if is_local_tunnel_reachable(session.local_port) {
self.append_log_with_level(
id,
"WARN",
"SSH ControlMaster check failed but local tunnel is still reachable",
);
return true;
}
self.append_log_with_level(
id,
"WARN",
"Existing SSH ControlMaster is not reachable",
);
return false;
}
} else if let Some(status) = session.master.try_wait().ok().flatten() {
if status.success() && is_control_master_alive(&session.parsed, &session.control_path) {
session.master_detached = true;
self.append_log_with_level(
id,
"INFO",
"SSH ControlMaster transitioned to detached background mode",
);
} else {
let mut stderr = String::new();
if let Some(mut stream) = session.master.stderr.take() {
let _ = stream.read_to_string(&mut stderr);
}
self.append_log_with_level(
id,
"WARN",
if stderr.trim().is_empty() {
format!("Existing SSH ControlMaster is not running ({status})")
} else {
format!(
"Existing SSH ControlMaster is not running ({status}): {}",
stderr.trim()
)
},
);
return false;
}
}
true
}
fn disconnect_internal(&self, app: &AppHandle, id: &str, report_idle: bool) {
self.cancel_connect_task(id);
self.cancel_monitor_task(id);
if let Some(mut session) = self.sessions.lock().expect("ssh sessions mutex").remove(id) {
if session.started_by_us
&& matches!(
session.instance.remote_openchamber.mode,
DesktopSshRemoteMode::Managed
)
&& !session.instance.remote_openchamber.keep_running
{
stop_remote_server_best_effort(
&session.parsed,
&session.control_path,
session.remote_port,
);
}
stop_control_master_best_effort(&session.parsed, &session.control_path);
kill_child(&mut session.main_forward);
for child in &mut session.extra_forwards {
kill_child(child);
}
kill_child(&mut session.master);
let _ = fs::remove_file(&session.control_path);
let _ = fs::remove_file(session.session_dir.join("askpass.sh"));
}
self.clear_retry_attempt(id);
if report_idle {
self.set_status(
app,
id,
DesktopSshPhase::Idle,
None,
None,
None,
None,
false,
0,
false,
);
}
}
fn ensure_remote_server(
&self,
app: &AppHandle,
instance: &DesktopSshInstance,
parsed: &DesktopSshParsedCommand,
control_path: &Path,
) -> Result<(u16, bool)> {
let app_version = app.package_info().version.to_string();
match instance.remote_openchamber.mode {
DesktopSshRemoteMode::External => {
let Some(port) = instance.remote_openchamber.preferred_port else {
return Err(anyhow!(
"External mode requires a preferred remote OpenChamber port"
));
};
self.set_status(
app,
&instance.id,
DesktopSshPhase::ServerDetecting,
Some("Probing external OpenChamber server".to_string()),
None,
None,
Some(port),
false,
0,
false,
);
probe_remote_system_info(
parsed,
control_path,
port,
configured_openchamber_password(instance),
)
.map_err(|err| {
anyhow!(format!(
"External OpenChamber server probe failed on configured remote port: {err}"
))
})?;
Ok((port, false))
}
DesktopSshRemoteMode::Managed => {
self.set_status(
app,
&instance.id,
DesktopSshPhase::RemoteProbe,
Some("Checking remote OpenChamber installation".to_string()),
None,
None,
None,
false,
0,
false,
);
let installed_version = current_remote_openchamber_version(parsed, control_path);
if installed_version.is_none() {
self.set_status(
app,
&instance.id,
DesktopSshPhase::Installing,
Some("Installing OpenChamber on remote host".to_string()),
None,
None,
None,
false,
0,
false,
);
install_openchamber_managed(
parsed,
control_path,
&app_version,
&instance.remote_openchamber.install_method,
)?;
} else if installed_version.as_deref() != Some(app_version.as_str()) {
self.set_status(
app,
&instance.id,
DesktopSshPhase::Updating,
Some(format!(
"Updating remote OpenChamber from {} to {}",
installed_version
.clone()
.unwrap_or_else(|| "unknown".to_string()),
app_version
)),
None,
None,
None,
false,
0,
false,
);
install_openchamber_managed(
parsed,
control_path,
&app_version,
&instance.remote_openchamber.install_method,
)?;
}
self.set_status(
app,
&instance.id,
DesktopSshPhase::ServerDetecting,
Some("Detecting managed OpenChamber server".to_string()),
None,
None,
None,
false,
0,
false,
);
let mut started_by_us = false;
let mut remote_port = instance.remote_openchamber.preferred_port;
if let Some(port) = remote_port {
if !remote_server_running(
parsed,
control_path,
port,
configured_openchamber_password(instance),
) {
remote_port = None;
}
}
if remote_port.is_none() {
self.set_status(
app,
&instance.id,
DesktopSshPhase::ServerStarting,
Some("Starting managed OpenChamber server".to_string()),
None,
None,
None,
false,
0,
false,
);
let desired_port = instance
.remote_openchamber
.preferred_port
.unwrap_or_else(|| random_port_candidate(&instance.id));
let started_port =
start_remote_server_managed(parsed, control_path, instance, desired_port)?;
remote_port = Some(started_port);
started_by_us = true;
}
let Some(port) = remote_port else {
return Err(anyhow!("Failed to determine remote OpenChamber port"));
};
if !remote_server_running(
parsed,
control_path,
port,
configured_openchamber_password(instance),
) {
return Err(anyhow!(
"Managed OpenChamber server failed to become reachable"
));
}
Ok((port, started_by_us))
}
}
}
fn connect_blocking(
self: &Arc<Self>,
app: &AppHandle,
instance: DesktopSshInstance,
) -> Result<()> {
let id = instance.id.clone();
self.set_status(
app,
&id,
DesktopSshPhase::ConfigResolved,
Some("Resolving SSH command".to_string()),
None,
None,
None,
false,
0,
false,
);
let parsed = instance
.ssh_parsed
.clone()
.or_else(|| parse_ssh_command(&instance.ssh_command).ok())
.ok_or_else(|| anyhow!("Invalid SSH command"))?;
let _resolved = resolve_ssh_config(&parsed)?;
self.set_status(
app,
&id,
DesktopSshPhase::AuthCheck,
Some("Checking SSH connectivity".to_string()),
None,
None,
None,
false,
0,
false,
);
let session_dir = ensure_session_dir(&id)?;
let control_path = control_path_for_instance(&session_dir, &id);
let _ = fs::remove_file(&control_path);
let askpass_path = session_dir.join("askpass.sh");
write_askpass_script(&askpass_path)?;
self.set_status(
app,
&id,
DesktopSshPhase::MasterConnecting,
Some("Establishing SSH ControlMaster".to_string()),
None,
None,
None,
false,
0,
false,
);
let mut master = spawn_master_process(
&parsed,
&control_path,
&askpass_path,
instance.auth.ssh_password.as_ref().and_then(|secret| {
if secret.enabled {
secret.value.as_deref()
} else {
None
}
}),
)?;
if let Err(err) = wait_for_master_ready(
&parsed,
&control_path,
instance.connection_timeout_sec,
&mut master,
) {
kill_child(&mut master);
return Err(err);
}
self.set_status(
app,
&id,
DesktopSshPhase::RemoteProbe,
Some("Probing remote platform".to_string()),
None,
None,
None,
false,
0,
false,
);
let remote_os = run_remote_command(
&parsed,
&control_path,
"uname -s",
instance.connection_timeout_sec,
)?;
let remote_os = remote_os.trim().to_ascii_lowercase();
if remote_os != "linux" && remote_os != "darwin" {
kill_child(&mut master);
return Err(anyhow!("Unsupported remote OS: {remote_os}"));
}
let (remote_port, started_by_us) =
match self.ensure_remote_server(app, &instance, &parsed, &control_path) {
Ok(result) => result,
Err(err) => {
kill_child(&mut master);
return Err(err);
}
};
self.set_status(
app,
&id,
DesktopSshPhase::Forwarding,
Some("Setting up port forwards".to_string()),
None,
None,
Some(remote_port),
started_by_us,
0,
false,
);
let bind_host = sanitize_bind_host(&instance.local_forward.bind_host);
let mut local_port = instance.local_forward.preferred_local_port.unwrap_or(0);
if local_port == 0 {
local_port = pick_unused_local_port()?;
}
if !is_local_port_available(&bind_host, local_port) {
local_port = pick_unused_local_port()?;
}
let mut main_forward =
match spawn_main_forward(&parsed, &control_path, &bind_host, local_port, remote_port) {
Ok(child) => child,
Err(err) => {
kill_child(&mut master);
return Err(err);
}
};
let mut main_forward_detached = false;
std::thread::sleep(Duration::from_millis(250));
if let Some(status) = main_forward.try_wait().ok().flatten() {
if status.success() {
main_forward_detached = true;
self.append_log_with_level(
&id,
"INFO",
"Main tunnel helper exited after ControlMaster handoff",
);
} else {
let mut stderr = String::new();
if let Some(mut stream) = main_forward.stderr.take() {
let _ = stream.read_to_string(&mut stderr);
}
kill_child(&mut master);
return Err(anyhow!(format!(
"Failed to start main port forward (status: {status}): {}",
stderr.trim()
)));
}
}
let mut extra_forwards = Vec::new();
let mut extra_errors = Vec::new();
for forward in instance
.port_forwards
.iter()
.filter(|forward| forward.enabled)
{
match spawn_extra_forward(&parsed, &control_path, forward) {
Ok(()) => {
if matches!(forward.forward_type, DesktopSshPortForwardType::Local) {
if let Some(local_port) = forward.local_port {
std::thread::sleep(Duration::from_millis(100));
if !is_local_tunnel_reachable(local_port) {
extra_errors.push(format!(
"{}: local listener 127.0.0.1:{} is not reachable",
forward.id, local_port
));
}
}
}
}
Err(err) => extra_errors.push(format!("{}: {}", forward.id, err)),
}
}
if let Err(err) = wait_local_forward_ready(local_port) {
kill_child(&mut main_forward);
for child in &mut extra_forwards {
kill_child(child);
}
kill_child(&mut master);
return Err(err);
}
let local_url = format!("http://127.0.0.1:{local_port}");
let label = build_display_label(&instance);
let _ = update_ssh_host_url(&id, &label, &local_url);
if instance.local_forward.preferred_local_port != Some(local_port) {
let _ = persist_local_port_for_instance(&id, local_port);
}
self.sessions.lock().expect("ssh sessions mutex").insert(
id.clone(),
SshSession {
instance: instance.clone(),
parsed,
session_dir,
control_path,
local_port,
remote_port,
started_by_us,
master,
master_detached: false,
main_forward,
main_forward_detached,
extra_forwards,
},
);
self.clear_retry_attempt(&id);
self.set_status(
app,
&id,
DesktopSshPhase::Ready,
if extra_errors.is_empty() {
Some("SSH instance is ready".to_string())
} else {
Some(format!(
"SSH instance is ready with forward warnings: {}",
extra_errors.join("; ")
))
},
Some(local_url),
Some(local_port),
Some(remote_port),
started_by_us,
0,
false,
);
self.spawn_monitor(app.clone(), id);
Ok(())
}
fn spawn_monitor(self: &Arc<Self>, app: AppHandle, id: String) {
self.cancel_monitor_task(&id);
let inner = Arc::clone(self);
let id_for_task = id.clone();
let handle = tauri::async_runtime::spawn(async move {
let mut healthy_ticks: u32 = 0;
loop {
let poll_secs = if healthy_ticks >= MONITOR_STABILIZE_TICKS {
MONITOR_STEADY_POLL_SECS
} else {
MONITOR_INITIAL_POLL_SECS
};
tokio::time::sleep(Duration::from_secs(poll_secs)).await;
let mut dropped_reason: Option<String> = None;
let mut detached_notice: Option<String> = None;
{
let mut sessions = inner.sessions.lock().expect("ssh sessions mutex");
let Some(session) = sessions.get_mut(&id_for_task) else {
break;
};
let mut main_anchor_alive = false;
if !session.main_forward_detached {
if let Some(status) = session.main_forward.try_wait().ok().flatten() {
if status.success() {
session.main_forward_detached = true;
detached_notice = Some(
"Main tunnel helper exited after ControlMaster handoff"
.to_string(),
);
} else {
let mut stderr = String::new();
if let Some(mut stream) = session.main_forward.stderr.take() {
let _ = stream.read_to_string(&mut stderr);
}
dropped_reason = Some(if stderr.trim().is_empty() {
format!("Main SSH forward exited ({status})")
} else {
format!("Main SSH forward exited ({status}): {}", stderr.trim())
});
}
} else {
main_anchor_alive = true;
}
}
if dropped_reason.is_none() {
if main_anchor_alive {
if !session.master_detached {
if let Some(status) = session.master.try_wait().ok().flatten() {
if status.success()
&& is_control_master_alive(
&session.parsed,
&session.control_path,
)
{
session.master_detached = true;
if detached_notice.is_none() {
detached_notice = Some(
"SSH ControlMaster transitioned to detached background mode"
.to_string(),
);
}
} else {
detached_notice = Some(
"SSH ControlMaster exited while main tunnel is still active"
.to_string(),
);
}
}
} else if !is_control_master_alive(
&session.parsed,
&session.control_path,
) {
detached_notice = Some(
"SSH ControlMaster is not reachable; main tunnel remains active"
.to_string(),
);
}
} else if session.master_detached {
// Fast path: check local tunnel first (cheap TCP probe)
// before spawning an SSH subprocess for control master check.
if is_local_tunnel_reachable(session.local_port) {
// Tunnel is alive — skip the expensive SSH check entirely.
} else if !is_control_master_alive(
&session.parsed,
&session.control_path,
) {
dropped_reason =
Some("SSH ControlMaster is not reachable".to_string());
} else {
detached_notice = Some(
"Local tunnel unreachable but ControlMaster is alive"
.to_string(),
);
}
} else if let Some(status) = session.master.try_wait().ok().flatten() {
if status.success()
&& is_control_master_alive(&session.parsed, &session.control_path)
{
session.master_detached = true;
if detached_notice.is_none() {
detached_notice = Some(
"SSH ControlMaster transitioned to detached background mode"
.to_string(),
);
}
} else {
let mut stderr = String::new();
if let Some(mut stream) = session.master.stderr.take() {
let _ = stream.read_to_string(&mut stderr);
}
dropped_reason = Some(if stderr.trim().is_empty() {
format!("SSH ControlMaster exited ({status})")
} else {
format!(
"SSH ControlMaster exited ({status}): {}",
stderr.trim()
)
});
}
}
}
}
if let Some(message) = detached_notice {
inner.append_log_with_level(&id_for_task, "INFO", message);
}
if dropped_reason.is_none() {
healthy_ticks = healthy_ticks.saturating_add(1);
continue;
}
let dropped_reason =
dropped_reason.unwrap_or_else(|| "SSH connection dropped".to_string());
inner.append_log_with_level(&id_for_task, "WARN", dropped_reason.clone());
inner.disconnect_internal(&app, &id_for_task, false);
let attempt = inner.next_retry_attempt(&id_for_task);
if attempt > DEFAULT_RECONNECT_MAX_ATTEMPTS {
inner.set_status(
&app,
&id_for_task,
DesktopSshPhase::Error,
Some(format!("{dropped_reason}. Retry limit reached")),
None,
None,
None,
false,
attempt,
true,
);
break;
}
inner.set_status(
&app,
&id_for_task,
DesktopSshPhase::Degraded,
Some(format!("{dropped_reason}. Reconnecting")),
None,
None,
None,
false,
attempt,
false,
);
let delay_ms =
(2u64.saturating_pow(attempt.saturating_sub(1))).saturating_mul(1000);
let jitter = (now_millis() % 700).saturating_add(100);
tokio::time::sleep(Duration::from_millis(
delay_ms.min(30_000).saturating_add(jitter),
))
.await;
if let Err(err) = inner.start_connect(app.clone(), id_for_task.clone()) {
inner.set_status(
&app,
&id_for_task,
DesktopSshPhase::Error,
Some(err),
None,
None,
None,
false,
attempt,
true,
);
}
break;
}
inner
.monitor_tasks
.lock()
.expect("ssh monitor task mutex")
.remove(&id_for_task);
});
self.monitor_tasks
.lock()
.expect("ssh monitor task mutex")
.insert(id, handle);
}
fn start_connect(self: &Arc<Self>, app: AppHandle, id: String) -> Result<(), String> {
let config = read_desktop_ssh_instances_from_disk();
let Some(instance) = config.instances.into_iter().find(|item| item.id == id) else {
return Err("SSH instance not found".to_string());
};
if self
.connect_tasks
.lock()
.expect("ssh connect task mutex")
.contains_key(&id)
{
self.append_log_with_level(&id, "INFO", "Connection already in progress");
return Ok(());
}
if self.session_is_alive(&id) {
let snapshot = self.status_snapshot_for_instance(&id);
self.set_status(
&app,
&id,
DesktopSshPhase::Ready,
Some("SSH session already active".to_string()),
snapshot.local_url,
snapshot.local_port,
snapshot.remote_port,
snapshot.started_by_us,
snapshot.retry_attempt,
false,
);
self.append_log_with_level(
&id,
"INFO",
"Connection already active; reusing existing SSH session",
);
return Ok(());
}
let retry_attempt = self.current_retry_attempt(&id);
let connect_attempt = self.next_connect_attempt(&id);
self.append_attempt_separator(&id, connect_attempt, retry_attempt);
self.append_log(&id, "Starting SSH connection");
self.disconnect_internal(&app, &id, false);
let id_for_task = id.clone();
let inner = Arc::clone(self);
let app_for_task = app.clone();
let handle = tauri::async_runtime::spawn(async move {
let result = tauri::async_runtime::spawn_blocking({
let inner = Arc::clone(&inner);
let app = app_for_task.clone();
let instance = instance.clone();
move || inner.connect_blocking(&app, instance)
})
.await;
match result {
Ok(Ok(())) => {}
Ok(Err(err)) => {
inner.set_status(
&app_for_task,
&id_for_task,
DesktopSshPhase::Error,
Some(err.to_string()),
None,
None,
None,
false,
0,
true,
);
inner.disconnect_internal(&app_for_task, &id_for_task, false);
}
Err(err) => {
inner.set_status(
&app_for_task,
&id_for_task,
DesktopSshPhase::Error,
Some(format!("SSH task failed: {err}")),
None,
None,
None,
false,
0,
true,
);
inner.disconnect_internal(&app_for_task, &id_for_task, false);
}
}
inner
.connect_tasks
.lock()
.expect("ssh connect task mutex")
.remove(&id_for_task);
});
self.connect_tasks
.lock()
.expect("ssh connect task mutex")
.insert(id, handle);
Ok(())
}
fn statuses_with_defaults(&self) -> Vec<DesktopSshInstanceStatus> {
let config = read_desktop_ssh_instances_from_disk();
let statuses = self.statuses.lock().expect("ssh status mutex");
let mut result = Vec::new();
for instance in config.instances {
result.push(
statuses
.get(&instance.id)
.cloned()
.unwrap_or_else(|| DesktopSshInstanceStatus::idle(instance.id)),
);
}
result.sort_by(|a, b| a.id.cmp(&b.id));
result
}
}
#[tauri::command]
pub fn desktop_ssh_logs(
state: State<'_, DesktopSshManagerState>,
id: String,
limit: Option<usize>,
) -> Result<Vec<String>, String> {
let id = id.trim().to_string();
if id.is_empty() || id == LOCAL_HOST_ID {
return Err("SSH instance id is required".to_string());
}
let cap = limit.unwrap_or(200).min(MAX_LOG_LINES_PER_INSTANCE);
Ok(state.inner.logs_for_instance(&id, cap))
}
#[tauri::command]
pub fn desktop_ssh_logs_clear(
state: State<'_, DesktopSshManagerState>,
id: String,
) -> Result<(), String> {
let id = id.trim().to_string();
if id.is_empty() || id == LOCAL_HOST_ID {
return Err("SSH instance id is required".to_string());
}
state.inner.clear_logs_for_instance(&id);
Ok(())
}
impl DesktopSshManagerState {
pub fn shutdown_all(&self, app: &AppHandle) {
let ids: Vec<String> = self
.inner
.sessions
.lock()
.expect("ssh sessions mutex")
.keys()
.cloned()
.collect();
for id in ids {
self.inner.disconnect_internal(app, &id, false);
}
let connect_ids: Vec<String> = self
.inner
.connect_tasks
.lock()
.expect("ssh connect task mutex")
.keys()
.cloned()
.collect();
for id in connect_ids {
self.inner.cancel_connect_task(&id);
}
let monitor_ids: Vec<String> = self
.inner
.monitor_tasks
.lock()
.expect("ssh monitor task mutex")
.keys()
.cloned()
.collect();
for id in monitor_ids {
self.inner.cancel_monitor_task(&id);
}
}
}
#[tauri::command]
pub fn desktop_ssh_instances_get() -> Result<DesktopSshInstancesConfig, String> {
Ok(read_desktop_ssh_instances_from_disk())
}
#[tauri::command]
pub fn desktop_ssh_instances_set(config: DesktopSshInstancesConfig) -> Result<(), String> {
write_desktop_ssh_instances_to_path(&settings_file_path(), config)
.map(|_| ())
.map_err(|err| err.to_string())
}
#[tauri::command]
pub fn desktop_ssh_import_hosts() -> Result<Vec<DesktopSshImportCandidate>, String> {
let mut candidates = Vec::new();
if let Some(home) = std::env::var_os("HOME") {
let user_config = PathBuf::from(home).join(".ssh").join("config");
candidates.extend(parse_ssh_config_candidates(&user_config, "user"));
}
candidates.extend(parse_ssh_config_candidates(
Path::new("/etc/ssh/ssh_config"),
"global",
));
let mut seen = HashSet::new();
candidates.retain(|item| seen.insert(item.host.clone()));
candidates.sort_by(|a, b| a.host.cmp(&b.host));
Ok(candidates)
}
#[tauri::command]
pub fn desktop_ssh_connect(
app: AppHandle,
state: State<'_, DesktopSshManagerState>,
id: String,
) -> Result<(), String> {
let id = id.trim().to_string();
if id.is_empty() || id == LOCAL_HOST_ID {
return Err("SSH instance id is required".to_string());
}
state.inner.start_connect(app, id)
}
#[tauri::command]
pub fn desktop_ssh_disconnect(
app: AppHandle,
state: State<'_, DesktopSshManagerState>,
id: String,
) -> Result<(), String> {
let id = id.trim().to_string();
if id.is_empty() || id == LOCAL_HOST_ID {
return Err("SSH instance id is required".to_string());
}
state.inner.disconnect_internal(&app, &id, true);
Ok(())
}
#[tauri::command]
pub fn desktop_ssh_status(
state: State<'_, DesktopSshManagerState>,
id: Option<String>,
) -> Result<Vec<DesktopSshInstanceStatus>, String> {
if let Some(instance_id) = id
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
return Ok(vec![state.inner.status_snapshot_for_instance(&instance_id)]);
}
Ok(state.inner.statuses_with_defaults())
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_instance(id: &str, command: &str) -> DesktopSshInstance {
DesktopSshInstance {
id: id.to_string(),
nickname: None,
ssh_command: command.to_string(),
ssh_parsed: None,
connection_timeout_sec: DEFAULT_CONNECTION_TIMEOUT_SEC,
remote_openchamber: DesktopSshRemoteOpenchamberConfig::default(),
local_forward: DesktopSshLocalForwardConfig::default(),
auth: DesktopSshAuthConfig::default(),
port_forwards: Vec::new(),
}
}
#[test]
fn parse_ssh_command_accepts_supported_options() {
let parsed = parse_ssh_command(
"ssh -J jump.example.com -o StrictHostKeyChecking=accept-new user@example.com",
)
.expect("parsed");
assert_eq!(parsed.destination, "user@example.com");
assert_eq!(
parsed.args,
vec![
"-J".to_string(),
"jump.example.com".to_string(),
"-o".to_string(),
"StrictHostKeyChecking=accept-new".to_string(),
]
);
}
#[test]
fn parse_ssh_command_rejects_disallowed_flags() {
let err = parse_ssh_command("ssh -M user@example.com")
.expect_err("should reject control master flag");
assert!(err.to_string().contains("not allowed"));
}
#[test]
fn parse_ssh_command_rejects_disallowed_controlpath_option() {
let err = parse_ssh_command("ssh -o ControlPath=/tmp/ssh.sock user@example.com")
.expect_err("should reject controlpath override");
assert!(err.to_string().contains("not allowed"));
}
#[test]
fn parse_ssh_command_keeps_ipv6_destination() {
let parsed =
parse_ssh_command("ssh user@[2001:db8::1]:2222").expect("parsed ipv6 destination");
assert_eq!(parsed.destination, "user@[2001:db8::1]:2222");
}
#[test]
fn sync_desktop_hosts_removes_deleted_ssh_hosts() {
let mut root = json!({
"desktopHosts": [
{"id": "ssh-old", "label": "Old", "url": "http://127.0.0.1:1"},
{"id": "http-1", "label": "HTTP", "url": "https://example.com"}
],
"desktopDefaultHostId": "ssh-old"
});
let mut previous = HashSet::new();
previous.insert("ssh-old".to_string());
let next = vec![sample_instance("ssh-new", "ssh user@example.com")];
sync_desktop_hosts_for_ssh(&mut root, &previous, &next);
let hosts = root
.get("desktopHosts")
.and_then(Value::as_array)
.expect("hosts array");
assert_eq!(hosts.len(), 2);
assert!(hosts
.iter()
.any(|item| item.get("id") == Some(&Value::String("http-1".to_string()))));
assert!(hosts
.iter()
.any(|item| item.get("id") == Some(&Value::String("ssh-new".to_string()))));
assert_eq!(
root.get("desktopDefaultHostId").and_then(Value::as_str),
Some("local")
);
}
#[test]
fn parse_ssh_config_candidates_extracts_host_entries() {
let temp =
std::env::temp_dir().join(format!("openchamber-ssh-import-{}.txt", now_millis()));
fs::write(
&temp,
"\nHost prod\n HostName 10.0.0.1\nHost *.dev !skip\nHost *\n",
)
.expect("write temp");
let candidates = parse_ssh_config_candidates(&temp, "user");
let _ = fs::remove_file(&temp);
assert!(candidates
.iter()
.any(|item| item.host == "prod" && !item.pattern));
assert!(candidates
.iter()
.any(|item| item.host == "*.dev" && item.pattern));
assert!(!candidates.iter().any(|item| item.host == "*"));
}
#[test]
fn sanitize_instance_applies_defaults_and_parsed_command() {
let mut instance = sample_instance("ssh-1", "ssh user@example.com");
instance.connection_timeout_sec = 0;
instance.local_forward.bind_host = "".to_string();
let normalized = sanitize_instance(instance).expect("sanitize instance");
assert_eq!(
normalized.connection_timeout_sec,
DEFAULT_CONNECTION_TIMEOUT_SEC
);
assert_eq!(normalized.local_forward.bind_host, "127.0.0.1");
assert_eq!(
normalized.ssh_parsed.expect("parsed").destination,
"user@example.com"
);
}
#[test]
fn parse_probe_status_line_extracts_numeric_status() {
assert_eq!(
parse_probe_status_line(Some("INFO_STATUS=401"), "INFO_STATUS="),
Some(401)
);
assert_eq!(
parse_probe_status_line(Some("INFO_STATUS=abc"), "INFO_STATUS="),
None
);
assert_eq!(
parse_probe_status_line(Some("WRONG=200"), "INFO_STATUS="),
None
);
}
#[test]
fn liveness_status_accepts_success_and_auth_challenges() {
assert!(is_liveness_http_status(200));
assert!(is_liveness_http_status(204));
assert!(is_liveness_http_status(401));
assert!(is_liveness_http_status(403));
assert!(!is_liveness_http_status(500));
assert!(!is_liveness_http_status(0));
}
}