rust: session manager (lifecycle, reconnect backoff, login scripts, recording, host-key policy, credential modes) and SSH tunnels

This commit is contained in:
Andy Dixon
2026-10-11 16:33:17 +01:00
parent 03bb1db936
commit cbd83489b2
6 changed files with 1383 additions and 2 deletions
+32 -2
View File
@@ -432,7 +432,33 @@ impl SshClient {
/// Connects, authenticates and starts the shell. Blocks the calling thread.
pub fn connect(self: &Arc<Self>, check: HostKeyCheck, password: Option<SecretFn>, passphrase: Option<SecretFn>) -> Result<()> {
let me = self.clone();
rt().block_on(async move { me.connect_async(check, password, passphrase).await })
rt().block_on(async move { me.connect_async(check, password, passphrase, true).await })
}
/// Connects and authenticates without opening a shell, for tunnelling another protocol.
pub fn connect_tunnel_only(self: &Arc<Self>, check: HostKeyCheck, password: Option<SecretFn>, passphrase: Option<SecretFn>) -> Result<()> {
let me = self.clone();
rt().block_on(async move { me.connect_async(check, password, passphrase, false).await })
}
/// A `direct-tcpip` channel to `host:port` presented as an ordinary loopback TCP stream,
/// so blocking clients (IRC, VNC) can use it with read timeouts.
pub fn open_tunnel(&self, host: &str, port: u16) -> Result<std::net::TcpStream> {
let h = self.handle()?;
let host = host.to_string();
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
let addr = listener.local_addr()?;
let ch = rt().block_on(h.channel_open_direct_tcpip(host.clone(), port as u32, "127.0.0.1", 0)).map_err(|e| format!("SSH tunnel to {host}:{port} failed: {e}"))?;
let client = std::net::TcpStream::connect(addr)?;
let (server, _) = listener.accept()?;
server.set_nonblocking(true)?;
rt().spawn(async move {
if let Ok(mut local) = tokio::net::TcpStream::from_std(server) {
let mut remote = ch.into_stream();
let _ = tokio::io::copy_bidirectional(&mut local, &mut remote).await;
}
});
Ok(client)
}
async fn handshake(
@@ -454,7 +480,7 @@ impl SshClient {
}
}
async fn connect_async(self: &Arc<Self>, check: HostKeyCheck, password: Option<SecretFn>, passphrase: Option<SecretFn>) -> Result<()> {
async fn connect_async(self: &Arc<Self>, check: HostKeyCheck, password: Option<SecretFn>, passphrase: Option<SecretFn>, shell: bool) -> Result<()> {
let sc = self.sc();
let p = &self.profile;
let port = if p.port == 0 { 22 } else { p.port as u16 };
@@ -484,6 +510,10 @@ impl SshClient {
)
.await?;
let handle = Arc::new(handle);
if !shell {
*self.handle.lock().unwrap() = Some(handle);
return Ok(());
}
let ch = handle.channel_open_session().await.map_err(|e| format!("failed to create SSH session: {e}"))?;
for (k, v) in crate::terminalenv::ssh_env_vars(&sc.env_vars) {
+1
View File
@@ -9,6 +9,7 @@ pub mod keyfiles;
pub mod models;
pub mod recording;
pub mod secrets;
pub mod sessions;
pub mod sshconfig;
pub mod storage;
pub mod terminalenv;
+50
View File
@@ -226,6 +226,56 @@ impl Keychain {
}
}
/// Keychain first, vault second — how every credential lookup in the app works.
pub struct Secrets {
pub vault: Vault,
pub keychain: Keychain,
/// Probed once at startup (the probe writes an item, so don't repeat it per lookup).
pub keychain_ok: bool,
}
impl Secrets {
pub fn new(vault: Vault, keychain: Keychain) -> Secrets {
let keychain_ok = keychain.available();
Secrets { vault, keychain, keychain_ok }
}
pub fn get(&self, key: &str) -> Option<String> {
if self.keychain_ok
&& let Some(v) = self.keychain.get(key).filter(|v| !v.is_empty())
{
return Some(v);
}
self.vault.get(key).ok().filter(|v| !v.is_empty())
}
/// Stores where possible: the keychain, else the vault if it's unlocked.
pub fn set(&self, key: &str, value: &str) {
let _ = self.set_checked(key, value);
}
pub fn set_checked(&self, key: &str, value: &str) -> Result<()> {
if self.keychain_ok && self.keychain.set(key, value).is_ok() {
return Ok(());
}
if !self.vault.is_unlocked() {
return Err("secret store is locked; unlock it first".into());
}
self.vault.set(key, value)
}
pub fn delete(&self, key: &str) {
if self.keychain_ok {
self.keychain.delete(key);
}
let _ = self.vault.delete(key);
}
pub fn delete_profile(&self, profile_id: &str) {
let _ = self.vault.delete_prefix(&format!("profile:{profile_id}:"));
}
}
#[cfg(test)]
mod tests {
use super::*;
File diff suppressed because it is too large Load Diff