Save SSH passwords in Windows Credential Manager and auto-type at prompt
This commit is contained in:
parent
872fb0e80e
commit
1c243b3f3f
11 changed files with 538 additions and 38 deletions
|
|
@ -18,6 +18,10 @@ tauri = { version = "2", features = [] }
|
||||||
tauri-plugin-clipboard-manager = "2"
|
tauri-plugin-clipboard-manager = "2"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
|
|
||||||
|
# Saved-credential storage (Windows Credential Manager / DPAPI).
|
||||||
|
keyring-core = "1"
|
||||||
|
windows-native-keyring-store = "1"
|
||||||
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
use crate::hosts::{self, SshHost};
|
use crate::creds;
|
||||||
|
use crate::hosts::{self, SshHost, SshHostView};
|
||||||
use crate::pty::{list_wsl_distros, PaneId, PtyManager, SpawnSpec};
|
use crate::pty::{list_wsl_distros, PaneId, PtyManager, SpawnSpec};
|
||||||
|
|
||||||
const WORKSPACE_FILE: &str = "workspace.json";
|
const WORKSPACE_FILE: &str = "workspace.json";
|
||||||
|
|
@ -92,11 +93,47 @@ pub async fn load_workspace(app: AppHandle) -> Result<Option<String>, String> {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn list_ssh_hosts(app: AppHandle) -> Result<Vec<SshHost>, String> {
|
pub async fn list_ssh_hosts(app: AppHandle) -> Result<Vec<SshHostView>, String> {
|
||||||
hosts::load(&app).map_err(|e| e.to_string())
|
let raw = hosts::load(&app).map_err(|e| e.to_string())?;
|
||||||
|
Ok(raw
|
||||||
|
.into_iter()
|
||||||
|
.map(|h| {
|
||||||
|
let has_password = creds::has(&h.id);
|
||||||
|
SshHostView { host: h, has_password }
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn save_ssh_hosts(app: AppHandle, hosts: Vec<SshHost>) -> Result<(), String> {
|
pub async fn save_ssh_hosts(app: AppHandle, hosts: Vec<SshHost>) -> Result<(), String> {
|
||||||
|
// Sweep orphaned credentials: any host id that existed before this call
|
||||||
|
// but isn't in the new list gets its keyring entry deleted. Saves the
|
||||||
|
// frontend from having to diff and call delete_host_password itself.
|
||||||
|
if let Ok(prior) = crate::hosts::load(&app) {
|
||||||
|
let new_ids: std::collections::HashSet<&str> =
|
||||||
|
hosts.iter().map(|h| h.id.as_str()).collect();
|
||||||
|
for old in &prior {
|
||||||
|
if !new_ids.contains(old.id.as_str()) {
|
||||||
|
if let Err(e) = creds::delete(&old.id) {
|
||||||
|
tracing::warn!("orphan credential cleanup failed for {}: {e}", old.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
crate::hosts::save(&app, &hosts).map_err(|e| e.to_string())
|
crate::hosts::save(&app, &hosts).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_host_password(host_id: String, password: String) -> Result<(), String> {
|
||||||
|
creds::set(&host_id, &password).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_host_password(host_id: String) -> Result<(), String> {
|
||||||
|
creds::delete(&host_id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn has_host_password(host_id: String) -> Result<bool, String> {
|
||||||
|
Ok(creds::has(&host_id))
|
||||||
|
}
|
||||||
|
|
|
||||||
46
src-tauri/src/creds.rs
Normal file
46
src-tauri/src/creds.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
//! Saved SSH-host credentials. Backed by Windows Credential Manager via
|
||||||
|
//! `keyring-core` + `windows-native-keyring-store` — passwords are DPAPI-
|
||||||
|
//! encrypted at rest and scoped to the user account. Never written to
|
||||||
|
//! disk in plaintext, never logged, never sent to the frontend.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use keyring_core::{Entry, Error as KeyringError};
|
||||||
|
|
||||||
|
const SERVICE: &str = "tiletopia";
|
||||||
|
|
||||||
|
fn target_for(host_id: &str) -> String {
|
||||||
|
format!("ssh-host:{host_id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry(host_id: &str) -> Result<Entry> {
|
||||||
|
Entry::new(SERVICE, &target_for(host_id))
|
||||||
|
.with_context(|| format!("create keyring entry for {host_id}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set(host_id: &str, password: &str) -> Result<()> {
|
||||||
|
entry(host_id)?
|
||||||
|
.set_password(password)
|
||||||
|
.with_context(|| format!("write credential for {host_id}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(host_id: &str) -> Result<Option<String>> {
|
||||||
|
match entry(host_id)?.get_password() {
|
||||||
|
Ok(p) => Ok(Some(p)),
|
||||||
|
Err(KeyringError::NoEntry) => Ok(None),
|
||||||
|
Err(e) => Err(anyhow::Error::from(e)
|
||||||
|
.context(format!("read credential for {host_id}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete(host_id: &str) -> Result<()> {
|
||||||
|
match entry(host_id)?.delete_credential() {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(KeyringError::NoEntry) => Ok(()),
|
||||||
|
Err(e) => Err(anyhow::Error::from(e)
|
||||||
|
.context(format!("delete credential for {host_id}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has(host_id: &str) -> bool {
|
||||||
|
matches!(get(host_id), Ok(Some(_)))
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,18 @@ use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
const HOSTS_FILE: &str = "hosts.json";
|
const HOSTS_FILE: &str = "hosts.json";
|
||||||
|
|
||||||
|
/// What `list_ssh_hosts` returns: the saved host plus a flag derived from
|
||||||
|
/// keyring (true iff a password is stored under this host's id). The flag
|
||||||
|
/// is read-only — saving a host doesn't touch the credential store. See
|
||||||
|
/// the dedicated set/delete password commands for that.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct SshHostView {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub host: SshHost,
|
||||||
|
#[serde(rename = "hasPassword")]
|
||||||
|
pub has_password: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// One saved host. Fields beyond `hostname` are optional; ssh.exe will fall
|
/// One saved host. Fields beyond `hostname` are optional; ssh.exe will fall
|
||||||
/// back to `~/.ssh/config` and its own defaults for anything we don't pass.
|
/// back to `~/.ssh/config` and its own defaults for anything we don't pass.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
//! Library entry point. `main.rs` calls `run()`.
|
//! Library entry point. `main.rs` calls `run()`.
|
||||||
|
|
||||||
mod commands;
|
mod commands;
|
||||||
|
mod creds;
|
||||||
mod hosts;
|
mod hosts;
|
||||||
mod pty;
|
mod pty;
|
||||||
|
|
||||||
|
|
@ -15,6 +16,16 @@ pub fn run() {
|
||||||
.with_writer(std::io::stderr)
|
.with_writer(std::io::stderr)
|
||||||
.try_init();
|
.try_init();
|
||||||
|
|
||||||
|
// keyring-core 1.x requires explicit store registration before any
|
||||||
|
// Entry::new() call. We're Windows-only so the Credential Manager
|
||||||
|
// backend is the only choice. Failure here means SSH passwords won't
|
||||||
|
// be retrievable — log and continue (host configs still work without
|
||||||
|
// saved passwords; users just see the prompt and type it manually).
|
||||||
|
match windows_native_keyring_store::Store::new() {
|
||||||
|
Ok(store) => keyring_core::set_default_store(store),
|
||||||
|
Err(e) => tracing::warn!("keyring store init failed: {e}"),
|
||||||
|
}
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_clipboard_manager::init())
|
.plugin(tauri_plugin_clipboard_manager::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
|
@ -29,6 +40,9 @@ pub fn run() {
|
||||||
commands::load_workspace,
|
commands::load_workspace,
|
||||||
commands::list_ssh_hosts,
|
commands::list_ssh_hosts,
|
||||||
commands::save_ssh_hosts,
|
commands::save_ssh_hosts,
|
||||||
|
commands::set_host_password,
|
||||||
|
commands::delete_host_password,
|
||||||
|
commands::has_host_password,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||||
|
|
@ -13,6 +15,8 @@ use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tauri::{AppHandle, Emitter};
|
use tauri::{AppHandle, Emitter};
|
||||||
|
|
||||||
|
use crate::creds;
|
||||||
|
|
||||||
pub type PaneId = u64;
|
pub type PaneId = u64;
|
||||||
|
|
||||||
/// Discriminated union describing what to spawn into a fresh PTY. Serialized
|
/// Discriminated union describing what to spawn into a fresh PTY. Serialized
|
||||||
|
|
@ -35,9 +39,19 @@ pub enum SpawnSpec {
|
||||||
jump_host: Option<String>,
|
jump_host: Option<String>,
|
||||||
#[serde(rename = "extraArgs")]
|
#[serde(rename = "extraArgs")]
|
||||||
extra_args: Option<Vec<String>>,
|
extra_args: Option<Vec<String>>,
|
||||||
|
/// SshHost.id (if any) — backend uses this to fetch a saved
|
||||||
|
/// password from keyring at spawn time. Never sent back to the
|
||||||
|
/// frontend.
|
||||||
|
#[serde(rename = "hostId")]
|
||||||
|
host_id: Option<String>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Type alias for the shared writer handle. Wrapped in Arc<Mutex<>> so the
|
||||||
|
/// reader thread can also take it briefly to autotype a saved password at
|
||||||
|
/// the SSH prompt.
|
||||||
|
type SharedWriter = Arc<Mutex<Box<dyn Write + Send>>>;
|
||||||
|
|
||||||
/// What we keep alive for each spawned PTY.
|
/// What we keep alive for each spawned PTY.
|
||||||
///
|
///
|
||||||
/// `master` stays in scope to keep the PTY alive; we never write through it
|
/// `master` stays in scope to keep the PTY alive; we never write through it
|
||||||
|
|
@ -46,7 +60,7 @@ pub enum SpawnSpec {
|
||||||
struct PaneHandle {
|
struct PaneHandle {
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
master: Box<dyn MasterPty + Send>,
|
master: Box<dyn MasterPty + Send>,
|
||||||
writer: Box<dyn Write + Send>,
|
writer: SharedWriter,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
child: Box<dyn portable_pty::Child + Send + Sync>,
|
child: Box<dyn portable_pty::Child + Send + Sync>,
|
||||||
}
|
}
|
||||||
|
|
@ -84,6 +98,21 @@ impl PtyManager {
|
||||||
})
|
})
|
||||||
.context("openpty failed")?;
|
.context("openpty failed")?;
|
||||||
|
|
||||||
|
// Look up any saved password BEFORE building the command (cheap, no
|
||||||
|
// bytes-on-the-wire involved). If this is an SSH spawn with a host
|
||||||
|
// id and the user has stored a credential, the reader thread will
|
||||||
|
// autotype it when ssh prompts.
|
||||||
|
let saved_password = match &spec {
|
||||||
|
SpawnSpec::Ssh { host_id: Some(id), .. } => match creds::get(id) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("keyring lookup for {id} failed: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
let (cmd, spawn_err) = build_command(&spec)?;
|
let (cmd, spawn_err) = build_command(&spec)?;
|
||||||
let child = pair.slave.spawn_command(cmd).context(spawn_err)?;
|
let child = pair.slave.spawn_command(cmd).context(spawn_err)?;
|
||||||
|
|
||||||
|
|
@ -93,10 +122,11 @@ impl PtyManager {
|
||||||
.master
|
.master
|
||||||
.try_clone_reader()
|
.try_clone_reader()
|
||||||
.context("try_clone_reader failed")?;
|
.context("try_clone_reader failed")?;
|
||||||
let writer = pair
|
let writer_raw = pair
|
||||||
.master
|
.master
|
||||||
.take_writer()
|
.take_writer()
|
||||||
.context("take_writer failed")?;
|
.context("take_writer failed")?;
|
||||||
|
let writer: SharedWriter = Arc::new(Mutex::new(writer_raw));
|
||||||
|
|
||||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||||
|
|
||||||
|
|
@ -104,16 +134,19 @@ impl PtyManager {
|
||||||
id,
|
id,
|
||||||
PaneHandle {
|
PaneHandle {
|
||||||
master: pair.master,
|
master: pair.master,
|
||||||
writer,
|
writer: writer.clone(),
|
||||||
child,
|
child,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reader thread: pump bytes -> base64 -> emit.
|
// Reader thread: pump bytes -> base64 -> emit. Also handles the
|
||||||
|
// password-prompt autotype state machine if `saved_password` is set.
|
||||||
let app_for_reader = app.clone();
|
let app_for_reader = app.clone();
|
||||||
let event_name = format!("pane://{id}/data");
|
let event_name = format!("pane://{id}/data");
|
||||||
|
let writer_for_reader = writer.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let mut buf = [0u8; 8192];
|
let mut buf = [0u8; 8192];
|
||||||
|
let mut pw_state = PasswordState::from(saved_password);
|
||||||
loop {
|
loop {
|
||||||
match reader.read(&mut buf) {
|
match reader.read(&mut buf) {
|
||||||
Ok(0) => {
|
Ok(0) => {
|
||||||
|
|
@ -122,6 +155,10 @@ impl PtyManager {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Ok(n) => {
|
Ok(n) => {
|
||||||
|
// Try to autotype before emitting so we don't wait
|
||||||
|
// on the renderer; pw_state mutates here.
|
||||||
|
pw_state.observe(&buf[..n], &writer_for_reader, id);
|
||||||
|
|
||||||
let chunk_b64 = B64.encode(&buf[..n]);
|
let chunk_b64 = B64.encode(&buf[..n]);
|
||||||
if let Err(e) =
|
if let Err(e) =
|
||||||
app_for_reader.emit(&event_name, DataChunk { b64: chunk_b64 })
|
app_for_reader.emit(&event_name, DataChunk { b64: chunk_b64 })
|
||||||
|
|
@ -142,12 +179,16 @@ impl PtyManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn write(&self, id: PaneId, bytes: &[u8]) -> Result<()> {
|
pub fn write(&self, id: PaneId, bytes: &[u8]) -> Result<()> {
|
||||||
let mut panes = self.panes.lock();
|
let writer = {
|
||||||
|
let panes = self.panes.lock();
|
||||||
let pane = panes
|
let pane = panes
|
||||||
.get_mut(&id)
|
.get(&id)
|
||||||
.ok_or_else(|| anyhow!("no pane with id {id}"))?;
|
.ok_or_else(|| anyhow!("no pane with id {id}"))?;
|
||||||
pane.writer.write_all(bytes).context("pty write failed")?;
|
pane.writer.clone()
|
||||||
pane.writer.flush().ok();
|
};
|
||||||
|
let mut w = writer.lock();
|
||||||
|
w.write_all(bytes).context("pty write failed")?;
|
||||||
|
w.flush().ok();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -279,6 +320,80 @@ fn build_command(spec: &SpawnSpec) -> Result<(CommandBuilder, &'static str)> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- password-prompt autotype ----------------------------------------------
|
||||||
|
|
||||||
|
/// How long after spawn we keep watching for a password prompt. If nothing
|
||||||
|
/// matches in this window, we disarm and never autotype — so a remote shell
|
||||||
|
/// that prints "password" hours later can't get our credential injected.
|
||||||
|
const PASSWORD_AUTOTYPE_WINDOW: Duration = Duration::from_secs(30);
|
||||||
|
/// Sliding window of recent PTY output we scan for the prompt. Keeps the
|
||||||
|
/// scan bounded; matches don't need much context.
|
||||||
|
const PROMPT_SCAN_TAIL: usize = 256;
|
||||||
|
|
||||||
|
enum PasswordState {
|
||||||
|
Disabled,
|
||||||
|
Armed {
|
||||||
|
password: String,
|
||||||
|
deadline: Instant,
|
||||||
|
tail: Vec<u8>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PasswordState {
|
||||||
|
fn from(password: Option<String>) -> Self {
|
||||||
|
match password {
|
||||||
|
None => Self::Disabled,
|
||||||
|
Some(p) => Self::Armed {
|
||||||
|
password: p,
|
||||||
|
deadline: Instant::now() + PASSWORD_AUTOTYPE_WINDOW,
|
||||||
|
tail: Vec::with_capacity(PROMPT_SCAN_TAIL * 2),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called for each chunk of PTY output. Mutates state — once we write
|
||||||
|
/// the password (or time out) the state collapses to Disabled and this
|
||||||
|
/// becomes a no-op for the rest of the connection.
|
||||||
|
fn observe(&mut self, chunk: &[u8], writer: &SharedWriter, pane_id: PaneId) {
|
||||||
|
let (password, tail, deadline) = match self {
|
||||||
|
PasswordState::Disabled => return,
|
||||||
|
PasswordState::Armed { password, tail, deadline } => (password, tail, deadline),
|
||||||
|
};
|
||||||
|
|
||||||
|
if Instant::now() > *deadline {
|
||||||
|
*self = PasswordState::Disabled;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tail.extend_from_slice(chunk);
|
||||||
|
if tail.len() > PROMPT_SCAN_TAIL {
|
||||||
|
let drop = tail.len() - PROMPT_SCAN_TAIL;
|
||||||
|
tail.drain(..drop);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !looks_like_password_prompt(tail) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match — write the password + Enter, then collapse to Disabled.
|
||||||
|
let mut w = writer.lock();
|
||||||
|
if let Err(e) = w.write_all(password.as_bytes()) {
|
||||||
|
tracing::warn!("pane {pane_id}: password autotype write failed: {e}");
|
||||||
|
}
|
||||||
|
let _ = w.write_all(b"\n");
|
||||||
|
let _ = w.flush();
|
||||||
|
*self = PasswordState::Disabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn looks_like_password_prompt(buf: &[u8]) -> bool {
|
||||||
|
// OpenSSH prompts: `<user>@<host>'s password:`, `Permission denied,
|
||||||
|
// please try again. password:`, `Enter passphrase for key '...':`.
|
||||||
|
// Lowercase the recent tail and substring-match — cheap and good enough.
|
||||||
|
let s = String::from_utf8_lossy(buf).to_ascii_lowercase();
|
||||||
|
s.contains("password:") || s.contains("passphrase")
|
||||||
|
}
|
||||||
|
|
||||||
// ---- distro enumeration -----------------------------------------------------
|
// ---- distro enumeration -----------------------------------------------------
|
||||||
|
|
||||||
/// Run a process without flashing a console window on Windows.
|
/// Run a process without flashing a console window on Windows.
|
||||||
|
|
|
||||||
39
src/App.tsx
39
src/App.tsx
|
|
@ -5,6 +5,8 @@ import {
|
||||||
saveWorkspace,
|
saveWorkspace,
|
||||||
listSshHosts,
|
listSshHosts,
|
||||||
saveSshHosts,
|
saveSshHosts,
|
||||||
|
setHostPassword,
|
||||||
|
deleteHostPassword,
|
||||||
writeToPane,
|
writeToPane,
|
||||||
killPane,
|
killPane,
|
||||||
type PaneId,
|
type PaneId,
|
||||||
|
|
@ -262,11 +264,42 @@ export default function App() {
|
||||||
|
|
||||||
const openHostManager = useCallback(() => setHostManagerOpen(true), []);
|
const openHostManager = useCallback(() => setHostManagerOpen(true), []);
|
||||||
const closeHostManager = useCallback(() => setHostManagerOpen(false), []);
|
const closeHostManager = useCallback(() => setHostManagerOpen(false), []);
|
||||||
const saveHosts = useCallback((next: SshHost[]) => {
|
const saveHosts = useCallback(
|
||||||
setHosts(next);
|
(next: SshHost[]) => {
|
||||||
|
// Preserve hasPassword flags that aren't included in the payload from
|
||||||
|
// HostManager (the manager strips them — backend recomputes on next
|
||||||
|
// list_ssh_hosts; we keep them locally so the badge doesn't flicker).
|
||||||
|
setHosts((prev) =>
|
||||||
|
next.map((h) => {
|
||||||
|
const hp = h.hasPassword ?? prev.find((p) => p.id === h.id)?.hasPassword;
|
||||||
|
return hp === undefined ? h : { ...h, hasPassword: hp };
|
||||||
|
}),
|
||||||
|
);
|
||||||
saveSshHosts(next).catch((e) =>
|
saveSshHosts(next).catch((e) =>
|
||||||
console.warn("saveSshHosts failed:", e),
|
console.warn("saveSshHosts failed:", e),
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const savePassword = useCallback((hostId: string, password: string) => {
|
||||||
|
setHostPassword(hostId, password).then(
|
||||||
|
() =>
|
||||||
|
setHosts((prev) =>
|
||||||
|
prev.map((h) => (h.id === hostId ? { ...h, hasPassword: true } : h)),
|
||||||
|
),
|
||||||
|
(e) => console.warn("setHostPassword failed:", e),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearPassword = useCallback((hostId: string) => {
|
||||||
|
deleteHostPassword(hostId).then(
|
||||||
|
() =>
|
||||||
|
setHosts((prev) =>
|
||||||
|
prev.map((h) => (h.id === hostId ? { ...h, hasPassword: false } : h)),
|
||||||
|
),
|
||||||
|
(e) => console.warn("deleteHostPassword failed:", e),
|
||||||
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ---- global keyboard shortcuts ------------------------------------------
|
// ---- global keyboard shortcuts ------------------------------------------
|
||||||
|
|
@ -715,6 +748,8 @@ export default function App() {
|
||||||
<HostManager
|
<HostManager
|
||||||
hosts={hosts}
|
hosts={hosts}
|
||||||
onSave={saveHosts}
|
onSave={saveHosts}
|
||||||
|
onSavePassword={savePassword}
|
||||||
|
onClearPassword={clearPassword}
|
||||||
onClose={closeHostManager}
|
onClose={closeHostManager}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,60 @@
|
||||||
color: #fcc;
|
color: #fcc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.host-pw-badge {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
vertical-align: middle;
|
||||||
|
filter: grayscale(0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-form-pw-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.host-form-pw-hint {
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
|
color: #555;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-form-pw-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.host-form-pw-row input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
.host-form-pw-reveal,
|
||||||
|
.host-form-pw-clear {
|
||||||
|
font: inherit;
|
||||||
|
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: #222;
|
||||||
|
color: #aaa;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.host-form-pw-reveal:hover,
|
||||||
|
.host-form-pw-clear:hover {
|
||||||
|
background: #2a2a2a;
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
.host-form-pw-clear {
|
||||||
|
color: #d88;
|
||||||
|
border-color: #3a1a1a;
|
||||||
|
}
|
||||||
|
.host-form-pw-clear:hover {
|
||||||
|
background: #3a1a1a;
|
||||||
|
color: #fcc;
|
||||||
|
}
|
||||||
|
|
||||||
.host-add-btn {
|
.host-add-btn {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
|
|
||||||
|
|
@ -19,22 +19,42 @@ function blankHost(): SshHost {
|
||||||
return { id: newId(), label: "", hostname: "" };
|
return { id: newId(), label: "", hostname: "" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-edit transient state for the password field. The actual password
|
||||||
|
* text never lives on `SshHost` — it stays in this map until the user
|
||||||
|
* clicks Save, at which point we either send a set/delete to keyring
|
||||||
|
* via the parent callbacks or do nothing. */
|
||||||
|
interface PasswordDraft {
|
||||||
|
/** What the user typed (or "" if untouched). */
|
||||||
|
input: string;
|
||||||
|
/** True iff the user clicked "Remove password" — overrides `input`. */
|
||||||
|
cleared: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface HostManagerProps {
|
interface HostManagerProps {
|
||||||
hosts: SshHost[];
|
hosts: SshHost[];
|
||||||
/** Called when the user clicks Save on a row. Returns a fresh list (with
|
/** Persist the host list (label/hostname/etc — no password). */
|
||||||
* the edit applied) to persist. The parent owns the canonical state. */
|
|
||||||
onSave: (hosts: SshHost[]) => void;
|
onSave: (hosts: SshHost[]) => void;
|
||||||
|
/** Write a new password to keyring for the given host id. Called only
|
||||||
|
* on Save, only when the user typed something into the password field. */
|
||||||
|
onSavePassword: (hostId: string, password: string) => void;
|
||||||
|
/** Delete the keyring entry for this host id. Called when the user
|
||||||
|
* clicked "Remove password" before Save. */
|
||||||
|
onClearPassword: (hostId: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HostManager({
|
export default function HostManager({
|
||||||
hosts,
|
hosts,
|
||||||
onSave,
|
onSave,
|
||||||
|
onSavePassword,
|
||||||
|
onClearPassword,
|
||||||
onClose,
|
onClose,
|
||||||
}: HostManagerProps) {
|
}: HostManagerProps) {
|
||||||
// Local editable copy. Any save / delete acts on this and pushes the
|
// Local editable copy. Any save / delete acts on this and pushes the
|
||||||
// whole list back up via onSave.
|
// whole list back up via onSave.
|
||||||
const [draft, setDraft] = useState<SshHost[]>(() => hosts.map((h) => ({ ...h })));
|
const [draft, setDraft] = useState<SshHost[]>(() => hosts.map((h) => ({ ...h })));
|
||||||
|
// Per-row password edits (keyed by host id). Absent = unchanged.
|
||||||
|
const [pwDrafts, setPwDrafts] = useState<Record<string, PasswordDraft>>({});
|
||||||
// Which row is being edited. null = list view only.
|
// Which row is being edited. null = list view only.
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const dialogRef = useRef<HTMLDivElement>(null);
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -48,20 +68,36 @@ export default function HostManager({
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const startEdit = useCallback((id: string) => setEditingId(id), []);
|
const startEdit = useCallback((id: string) => {
|
||||||
|
setEditingId(id);
|
||||||
|
setPwDrafts((cur) => {
|
||||||
|
if (cur[id]) return cur;
|
||||||
|
return { ...cur, [id]: { input: "", cleared: false } };
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const cancelEdit = useCallback(() => {
|
const cancelEdit = useCallback(() => {
|
||||||
// Revert any unsaved edits to that row from props.
|
// Revert any unsaved edits to that row from props; drop password drafts.
|
||||||
setDraft((cur) =>
|
setDraft((cur) =>
|
||||||
cur.map((h) => {
|
cur
|
||||||
|
.map((h) => {
|
||||||
if (h.id !== editingId) return h;
|
if (h.id !== editingId) return h;
|
||||||
const original = hosts.find((o) => o.id === editingId);
|
const original = hosts.find((o) => o.id === editingId);
|
||||||
// Newly-added row that was never saved? Drop it entirely on cancel.
|
|
||||||
return original ?? h;
|
return original ?? h;
|
||||||
}).filter((h) => {
|
})
|
||||||
|
.filter((h) => {
|
||||||
if (h.id !== editingId) return true;
|
if (h.id !== editingId) return true;
|
||||||
return hosts.some((o) => o.id === editingId);
|
return hosts.some((o) => o.id === editingId);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
if (editingId) {
|
||||||
|
setPwDrafts((cur) => {
|
||||||
|
if (!(editingId in cur)) return cur;
|
||||||
|
const next = { ...cur };
|
||||||
|
delete next[editingId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
}, [editingId, hosts]);
|
}, [editingId, hosts]);
|
||||||
|
|
||||||
|
|
@ -95,6 +131,20 @@ export default function HostManager({
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onPasswordInput = useCallback((id: string, value: string) => {
|
||||||
|
setPwDrafts((cur) => ({
|
||||||
|
...cur,
|
||||||
|
[id]: { input: value, cleared: false },
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onPasswordClear = useCallback((id: string) => {
|
||||||
|
setPwDrafts((cur) => ({
|
||||||
|
...cur,
|
||||||
|
[id]: { input: "", cleared: true },
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const saveRow = useCallback(
|
const saveRow = useCallback(
|
||||||
(id: string, e: FormEvent) => {
|
(id: string, e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -111,20 +161,52 @@ export default function HostManager({
|
||||||
label: row.label.trim() || row.hostname.trim(),
|
label: row.label.trim() || row.hostname.trim(),
|
||||||
hostname: row.hostname.trim(),
|
hostname: row.hostname.trim(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Apply the password edit — if any — BEFORE flipping `hasPassword`
|
||||||
|
// on the local copy so the row redraws with the right state.
|
||||||
|
const pw = pwDrafts[id];
|
||||||
|
let nextHasPassword = row.hasPassword;
|
||||||
|
if (pw) {
|
||||||
|
if (pw.cleared) {
|
||||||
|
onClearPassword(id);
|
||||||
|
nextHasPassword = false;
|
||||||
|
} else if (pw.input.length > 0) {
|
||||||
|
onSavePassword(id, pw.input);
|
||||||
|
nextHasPassword = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleaned.hasPassword = nextHasPassword;
|
||||||
|
|
||||||
const next = draft.map((h) => (h.id === id ? cleaned : h));
|
const next = draft.map((h) => (h.id === id ? cleaned : h));
|
||||||
setDraft(next);
|
setDraft(next);
|
||||||
onSave(next);
|
onSave(next.map(({ hasPassword: _hp, ...rest }) => rest));
|
||||||
|
// Drop the pw draft so re-edit doesn't carry it over.
|
||||||
|
setPwDrafts((cur) => {
|
||||||
|
if (!(id in cur)) return cur;
|
||||||
|
const nxt = { ...cur };
|
||||||
|
delete nxt[id];
|
||||||
|
return nxt;
|
||||||
|
});
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
},
|
},
|
||||||
[draft, onSave],
|
[draft, pwDrafts, onSave, onSavePassword, onClearPassword],
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeRow = useCallback(
|
const removeRow = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
const next = draft.filter((h) => h.id !== id);
|
const next = draft.filter((h) => h.id !== id);
|
||||||
setDraft(next);
|
setDraft(next);
|
||||||
onSave(next);
|
// Strip hasPassword on persist — the backend recomputes it. (The
|
||||||
|
// save command sweeps orphan credentials, so the deleted host's
|
||||||
|
// password is also removed from keyring.)
|
||||||
|
onSave(next.map(({ hasPassword: _hp, ...rest }) => rest));
|
||||||
if (editingId === id) setEditingId(null);
|
if (editingId === id) setEditingId(null);
|
||||||
|
setPwDrafts((cur) => {
|
||||||
|
if (!(id in cur)) return cur;
|
||||||
|
const nxt = { ...cur };
|
||||||
|
delete nxt[id];
|
||||||
|
return nxt;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[draft, editingId, onSave],
|
[draft, editingId, onSave],
|
||||||
);
|
);
|
||||||
|
|
@ -133,6 +215,10 @@ export default function HostManager({
|
||||||
const fresh = blankHost();
|
const fresh = blankHost();
|
||||||
setDraft((cur) => [...cur, fresh]);
|
setDraft((cur) => [...cur, fresh]);
|
||||||
setEditingId(fresh.id);
|
setEditingId(fresh.id);
|
||||||
|
setPwDrafts((cur) => ({
|
||||||
|
...cur,
|
||||||
|
[fresh.id]: { input: "", cleared: false },
|
||||||
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -246,14 +332,19 @@ export default function HostManager({
|
||||||
placeholder="-o ServerAliveInterval=30"
|
placeholder="-o ServerAliveInterval=30"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<PasswordField
|
||||||
|
hostHasPassword={!!h.hasPassword}
|
||||||
|
draft={pwDrafts[h.id]}
|
||||||
|
onChange={(v) => onPasswordInput(h.id, v)}
|
||||||
|
onClear={() => onPasswordClear(h.id)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="host-form-actions">
|
<div className="host-form-actions">
|
||||||
<button type="submit" className="primary">
|
<button type="submit" className="primary">
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" onClick={cancelEdit}>
|
||||||
type="button"
|
|
||||||
onClick={cancelEdit}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
|
@ -270,6 +361,14 @@ export default function HostManager({
|
||||||
<div className="host-summary">
|
<div className="host-summary">
|
||||||
<div className="host-summary-label">
|
<div className="host-summary-label">
|
||||||
{h.label || h.hostname}
|
{h.label || h.hostname}
|
||||||
|
{h.hasPassword && (
|
||||||
|
<span
|
||||||
|
className="host-pw-badge"
|
||||||
|
title="Password stored in Windows Credential Manager"
|
||||||
|
>
|
||||||
|
🔒
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="host-summary-detail">
|
<div className="host-summary-detail">
|
||||||
{h.user ? `${h.user}@` : ""}
|
{h.user ? `${h.user}@` : ""}
|
||||||
|
|
@ -299,3 +398,66 @@ export default function HostManager({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PasswordField({
|
||||||
|
hostHasPassword,
|
||||||
|
draft,
|
||||||
|
onChange,
|
||||||
|
onClear,
|
||||||
|
}: {
|
||||||
|
hostHasPassword: boolean;
|
||||||
|
draft: PasswordDraft | undefined;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}) {
|
||||||
|
const [reveal, setReveal] = useState(false);
|
||||||
|
const cleared = draft?.cleared ?? false;
|
||||||
|
const showClearButton = hostHasPassword && !cleared;
|
||||||
|
|
||||||
|
const placeholder = cleared
|
||||||
|
? "(password will be removed on save)"
|
||||||
|
: hostHasPassword
|
||||||
|
? "(saved — leave blank to keep, or type new)"
|
||||||
|
: "password (optional)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label>
|
||||||
|
<span className="host-form-pw-label">
|
||||||
|
Password
|
||||||
|
<span
|
||||||
|
className="host-form-pw-hint"
|
||||||
|
title="Stored in Windows Credential Manager; auto-typed at the ssh password prompt on connect."
|
||||||
|
>
|
||||||
|
stored encrypted; auto-typed at prompt
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<div className="host-form-pw-row">
|
||||||
|
<input
|
||||||
|
type={reveal ? "text" : "password"}
|
||||||
|
value={draft?.input ?? ""}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="host-form-pw-reveal"
|
||||||
|
onClick={() => setReveal((r) => !r)}
|
||||||
|
title={reveal ? "Hide" : "Show"}
|
||||||
|
>
|
||||||
|
{reveal ? "🙈" : "👁"}
|
||||||
|
</button>
|
||||||
|
{showClearButton && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="host-form-pw-clear"
|
||||||
|
onClick={onClear}
|
||||||
|
title="Remove the saved password from keyring on next Save"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
22
src/ipc.ts
22
src/ipc.ts
|
|
@ -15,9 +15,13 @@ export type SpawnSpec =
|
||||||
identityFile?: string;
|
identityFile?: string;
|
||||||
jumpHost?: string;
|
jumpHost?: string;
|
||||||
extraArgs?: string[];
|
extraArgs?: string[];
|
||||||
|
/** Backend uses this to look up a saved password from keyring at
|
||||||
|
* spawn time. Never echoed back to the frontend. */
|
||||||
|
hostId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** One saved SSH host. Mirrors the Rust `SshHost` struct. */
|
/** One saved SSH host. Mirrors the Rust `SshHost` struct (plus the
|
||||||
|
* `hasPassword` flag that the backend sets when listing). */
|
||||||
export interface SshHost {
|
export interface SshHost {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -27,6 +31,10 @@ export interface SshHost {
|
||||||
identityFile?: string;
|
identityFile?: string;
|
||||||
jumpHost?: string;
|
jumpHost?: string;
|
||||||
extraArgs?: string[];
|
extraArgs?: string[];
|
||||||
|
/** True iff a credential is stored under this host's id in the system
|
||||||
|
* keyring. Set by the backend on `list_ssh_hosts`; the field is
|
||||||
|
* ignored on `save_ssh_hosts` (use the password commands below). */
|
||||||
|
hasPassword?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const listDistros = (): Promise<string[]> => invoke("list_distros");
|
export const listDistros = (): Promise<string[]> => invoke("list_distros");
|
||||||
|
|
@ -70,3 +78,15 @@ export const listSshHosts = (): Promise<SshHost[]> => invoke("list_ssh_hosts");
|
||||||
|
|
||||||
export const saveSshHosts = (hosts: SshHost[]): Promise<void> =>
|
export const saveSshHosts = (hosts: SshHost[]): Promise<void> =>
|
||||||
invoke("save_ssh_hosts", { hosts });
|
invoke("save_ssh_hosts", { hosts });
|
||||||
|
|
||||||
|
/** Store / replace the saved password for this host id. Plaintext is
|
||||||
|
* IPC'd to the Rust side (in-process, no disk hop) and immediately
|
||||||
|
* written to Windows Credential Manager (DPAPI). */
|
||||||
|
export const setHostPassword = (hostId: string, password: string): Promise<void> =>
|
||||||
|
invoke("set_host_password", { hostId, password });
|
||||||
|
|
||||||
|
export const deleteHostPassword = (hostId: string): Promise<void> =>
|
||||||
|
invoke("delete_host_password", { hostId });
|
||||||
|
|
||||||
|
export const hasHostPassword = (hostId: string): Promise<boolean> =>
|
||||||
|
invoke("has_host_password", { hostId });
|
||||||
|
|
|
||||||
|
|
@ -288,6 +288,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
||||||
identityFile: host.identityFile,
|
identityFile: host.identityFile,
|
||||||
jumpHost: host.jumpHost,
|
jumpHost: host.jumpHost,
|
||||||
extraArgs: host.extraArgs,
|
extraArgs: host.extraArgs,
|
||||||
|
hostId: host.id,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue