From b149fb3b38d42796609af35911c52b0e362d2b08 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Mon, 12 Oct 2026 16:57:56 +0100 Subject: [PATCH] refactor: split the session manager into modules sessions.rs (1 730 lines) becomes sessions/ with mod.rs (types, session lifecycle, I/O, recording, tests), credentials.rs (secret resolution, prompts, host-key checks), protocols.rs (SSH, GitHub, IRC, VNC, RDP connects and SSH tunnels), local.rs (local shells, login scripts) and extras.rs (per-protocol accessors). Code moves unchanged; helpers that crossed a module boundary became pub(super). --- rust/core/src/sessions.rs | 1733 ------------------------- rust/core/src/sessions/credentials.rs | 189 +++ rust/core/src/sessions/extras.rs | 114 ++ rust/core/src/sessions/local.rs | 152 +++ rust/core/src/sessions/mod.rs | 895 +++++++++++++ rust/core/src/sessions/protocols.rs | 413 ++++++ 6 files changed, 1763 insertions(+), 1733 deletions(-) delete mode 100644 rust/core/src/sessions.rs create mode 100644 rust/core/src/sessions/credentials.rs create mode 100644 rust/core/src/sessions/extras.rs create mode 100644 rust/core/src/sessions/local.rs create mode 100644 rust/core/src/sessions/mod.rs create mode 100644 rust/core/src/sessions/protocols.rs diff --git a/rust/core/src/sessions.rs b/rust/core/src/sessions.rs deleted file mode 100644 index 8aa695f..0000000 --- a/rust/core/src/sessions.rs +++ /dev/null @@ -1,1733 +0,0 @@ -//! The session manager: one entry per open tab, each owning a protocol client. A port of -//! internal/sessions — same lifecycle, reconnect backoff, login scripts, recording hooks and -//! credential resolution — reporting to the UI through `Event`s. - -use crate::Result; -use crate::clients::local::LocalShell; -use crate::clients::ssh::{HostKeyCheck, HostKeyInfo, SecretFn, SshClient}; -use crate::clients::{self, Callbacks, github, irc, raw, serial, telnet}; -use crate::models::*; -use crate::recording::{self, Recorder}; -use crate::secrets::{SecretKind, Secrets}; -use crate::storage::Store; -use parking_lot::Mutex; -use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::mpsc; -use std::time::Duration; -use zeroize::Zeroizing; - -/// Everything the UI hears about. Prompts carry their reply channel; dropping it cancels. -pub enum Event { - Data { - session: String, - data: Vec, - }, - State { - session: String, - state: SessionState, - error: String, - retry: Option, - }, - Closed { - session: String, - }, - RawData { - session: String, - data: Vec, - direction: String, - timestamp: i64, - }, - Debug { - session: String, - message: String, - }, - GithubStatus { - session: String, - status: String, - }, - GithubRepos { - session: String, - login: String, - repos: Vec, - }, - GithubError { - session: String, - error: String, - }, - /// Something the user should know about that didn't stop the session (e.g. a credential that - /// couldn't be saved). - Warning { - session: String, - message: String, - }, - Irc { - session: String, - event: irc::Event, - }, - ForwardStarted { - session: String, - forward: PortForward, - }, - ForwardStopped { - session: String, - forward_id: String, - }, - ForwardError { - session: String, - forward_id: String, - label: String, - error: String, - }, - RecordingStarted { - session: String, - }, - RecordingStopped { - session: String, - duration: f64, - events: usize, - }, - SftpProgress { - session: String, - progress: clients::sftp::Progress, - }, - SecretPrompt { - session: String, - title: String, - message: String, - reply: mpsc::Sender, - }, - /// A host key needs the user's decision: unknown (`stored` is None) or changed (`stored` is - /// the fingerprint on record). - HostKeyVerify { - session: String, - host: String, - key_type: String, - fingerprint: String, - stored: Option, - reply: mpsc::Sender, - }, - DesktopReady { - session: String, - }, - DesktopFrame { - session: String, - }, - /// The remote desktop's clipboard changed. - VncClipboard { - session: String, - text: String, - }, -} - -/// The user's answer to a host-key prompt. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum HostKeyDecision { - Reject, - /// Connect, leaving the known-hosts record as it is. - AcceptOnce, - /// Connect and record this key (replacing a changed one). - AcceptAndSave, -} - -#[derive(Clone, Copy, Debug)] -pub struct Retry { - pub attempt: i64, - pub max: i64, - pub delay_secs: u64, -} - -pub type EventSink = Arc; -/// Asks the user for a credential (blocks the calling worker thread). -pub type Prompt = Arc Result + Send + Sync>; - -#[derive(Clone)] -enum Client { - None, - Ssh(Arc), - Telnet(Arc), - Raw(Arc), - Serial(Arc), - Local(Arc), - Github(Arc, Arc), - Irc(Arc, Option>), - Vnc(Arc, Option>), - Rdp(Arc, Option>), -} - -impl Client { - fn close(&self) { - match self { - Client::None => {} - Client::Ssh(c) => c.close(), - Client::Telnet(c) => c.close(), - Client::Raw(c) => c.close(), - Client::Serial(c) => c.close(), - Client::Local(c) => c.close(), - Client::Github(_, stop) => stop.store(true, Ordering::SeqCst), - Client::Irc(c, tunnel) => { - c.close(); - if let Some(t) = tunnel { - t.close(); - } - } - Client::Vnc(c, tunnel) => { - c.close(); - if let Some(t) = tunnel { - t.close(); - } - } - Client::Rdp(c, tunnel) => { - c.close(); - if let Some(t) = tunnel { - t.close(); - } - } - } - } -} - -#[derive(Default)] -struct LoginBuf { - active: bool, - buf: Vec, -} - -struct Session { - info: SessionInfo, - /// Distinguishes this Session object from a later one with the same id (Go compared pointers). - epoch: u64, - closed: bool, - client: Client, - profile: Option, - local_shell: String, - local_cwd: Option, - recorder: Option>, - reconnect_count: i64, - cancel_reconnect: Option>, - pending_resize: Option<(u16, u16)>, - login: Arc>, - /// Credential state kept across reconnects of this session. - session_secrets: SessionSecrets, -} - -type SessionSecrets = Arc>; - -#[derive(Default)] -struct SecretState { - /// `SecretMode::SessionOnly` values, asked once per session. Wiped from memory on drop. - session_only: HashMap>, - /// `SecretMode::Saved` values typed during this attempt; stored only once it connects, so a - /// typo never ends up saved. - pending: HashMap>, - /// Saved values this attempt used. - used: HashSet, - /// Saved values the server rejected: the next attempt asks again instead of reusing them. - rejected: HashSet, -} - -/// After a failed attempt, typed credentials are dropped and, when the server refused them, the -/// saved ones used are skipped next time so the user can enter a replacement. -fn attempt_failed(state: &SessionSecrets, error: &str) { - let mut st = state.lock(); - st.pending.clear(); - if is_auth_error(error) { - let used = std::mem::take(&mut st.used); - st.rejected.extend(used); - } -} - -/// Whether a connect error means the server refused the credentials (as opposed to a network or -/// host-key failure). -pub fn is_auth_error(msg: &str) -> bool { - let m = msg.to_lowercase(); - // The last two are CredSSP's (RDP with NLA) wording for a rejected password. - ["unable to authenticate", "authentication failed", "permission denied", "no supported methods", "logondenied", "logon failure"] - .iter() - .any(|p| m.contains(p)) -} - -pub struct Manager { - sessions: Mutex>, - store: Arc, - secrets: Arc, - recordings: recording::Store, - sink: Mutex>, - next_epoch: std::sync::atomic::AtomicU64, -} - -fn err(msg: String) -> Result { - Err(msg.into()) -} - -impl Manager { - pub fn new(store: Arc, secrets: Arc, recordings_dir: PathBuf) -> Arc { - Arc::new(Manager { - sessions: Mutex::new(HashMap::new()), - store, - secrets, - recordings: recording::Store::new(recordings_dir), - sink: Mutex::new(None), - next_epoch: 1.into(), - }) - } - - pub fn set_sink(&self, sink: EventSink) { - *self.sink.lock() = Some(sink); - } - - fn emit(&self, e: Event) { - let sink = self.sink.lock().clone(); - if let Some(s) = sink { - s(e); - } - } - - fn emit_state(&self, id: &str, state: SessionState, error: &str) { - self.emit(Event::State { session: id.into(), state, error: error.into(), retry: None }); - } - - /// True while `id` still names the Session object with `epoch` and it isn't closed. - fn is_current(&self, id: &str, epoch: u64) -> bool { - self.sessions.lock().get(id).is_some_and(|s| s.epoch == epoch && !s.closed) - } - - fn with(&self, id: &str, f: impl FnOnce(&mut Session) -> R) -> Result { - let mut map = self.sessions.lock(); - match map.get_mut(id) { - Some(s) => Ok(f(s)), - None => err(format!("sessions: session {id:?} not found")), - } - } - - fn client(&self, id: &str) -> Result { - self.with(id, |s| s.client.clone()) - } - - fn new_session(&self, info: SessionInfo, profile: Option, shell: String, session_secrets: SessionSecrets) -> u64 { - let epoch = self.next_epoch.fetch_add(1, Ordering::SeqCst); - let s = Session { - info, - epoch, - closed: false, - client: Client::None, - profile, - local_shell: shell, - local_cwd: None, - recorder: None, - reconnect_count: 0, - cancel_reconnect: None, - pending_resize: None, - login: Arc::default(), - session_secrets, - }; - let id = s.info.id.clone(); - self.sessions.lock().insert(id, s); - epoch - } - - /// Opens a session for `profile` and connects it on a worker thread. Returns the session id - /// immediately; progress arrives as `Event::State`. - pub fn connect(self: &Arc, profile: Profile) -> String { - self.connect_with(profile, SessionSecrets::default()) - } - - /// `connect` with session-only credentials carried over from a previous session (reconnect). - fn connect_with(self: &Arc, profile: Profile, session_secrets: SessionSecrets) -> String { - let id = new_id(); - let prompt = self.prompt_for(&id, "Credentials Required", Duration::from_secs(300)); - let info = SessionInfo { - id: id.clone(), - profile_id: profile.id.clone(), - profile_name: profile.display_name.clone(), - protocol: profile.protocol, - host: profile.host.clone(), - port: profile.port, - state: SessionState::Connecting, - tab_name: profile.tab_name(), - pinned: false, - connected_at: now(), - error: String::new(), - }; - let epoch = self.new_session(info, Some(profile.clone()), String::new(), session_secrets); - self.emit_state(&id, SessionState::Connecting, ""); - let me = self.clone(); - let id2 = id.clone(); - std::thread::spawn(move || { - let r = me.connect_protocol(&id2, epoch, &profile, prompt); - me.finish_connect(&id2, epoch, r, |me| { - me.store.add_audit("connect", &profile.id, &id2, &format!("Connected via {} to {}", profile.protocol.id(), profile.host)) - }); - }); - id - } - - fn finish_connect(&self, id: &str, epoch: u64, r: Result, on_ok: impl FnOnce(&Self)) { - let mut map = self.sessions.lock(); - let current = map.get_mut(id).filter(|s| s.epoch == epoch && !s.closed); - match (r, current) { - (Ok(client), None) => { - drop(map); - client.close(); - } - (Err(_), None) => {} - (Err(e), Some(s)) => { - s.info.state = SessionState::Error; - s.info.error = e.to_string(); - attempt_failed(&s.session_secrets, &e.to_string()); - drop(map); - self.emit_state(id, SessionState::Error, &e.to_string()); - } - (Ok(client), Some(s)) => { - let typed = std::mem::take(&mut s.session_secrets.lock().pending); - s.client = client.clone(); - s.info.state = SessionState::Connected; - s.info.connected_at = now(); - s.reconnect_count = 0; - let pending = s.pending_resize.take(); - drop(map); - if let Some((c, r)) = pending { - let _ = self.resize_client(&client, c, r); - } - for (key, value) in typed { - if let Err(e) = self.secrets.set_checked(&key, &value) { - self.emit(Event::Warning { session: id.into(), message: format!("Credential not saved: {e}") }); - } - } - self.emit_state(id, SessionState::Connected, ""); - on_ok(self); - } - } - } - - fn connect_protocol(self: &Arc, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result { - let _ = self.with(id, |s| { - let mut st = s.session_secrets.lock(); - st.pending.clear(); - st.used.clear(); - }); - match p.protocol { - Protocol::Ssh => self.connect_ssh(id, epoch, p, prompt), - Protocol::Telnet => { - let debug = p.telnet.as_ref().is_some_and(|t| t.negotiation_debug).then(|| { - let (me, id) = (self.clone(), id.to_string()); - Arc::new(move |m: String| me.emit(Event::Debug { session: id.clone(), message: m })) as clients::DebugFn - }); - let c = Arc::new(telnet::TelnetClient::new(p, self.terminal_callbacks(id, epoch), debug)); - c.connect()?; - self.start_login_scripts(id, epoch, p.telnet.as_ref().map(|t| t.login_scripts.clone()).unwrap_or_default()); - Ok(Client::Telnet(c)) - } - Protocol::Raw => { - let cb = self.terminal_callbacks(id, epoch); - let (me, sid) = (self.clone(), id.to_string()); - let on_data: raw::RawDataFn = - Arc::new(move |d, dir, ts| me.emit(Event::RawData { session: sid.clone(), data: d.to_vec(), direction: dir.into(), timestamp: ts })); - let c = Arc::new(raw::RawClient::new(p, on_data, cb.on_error, cb.on_close)); - c.connect()?; - Ok(Client::Raw(c)) - } - Protocol::Serial => { - let c = Arc::new(serial::SerialClient::new(p, self.terminal_callbacks(id, epoch))); - c.connect()?; - Ok(Client::Serial(c)) - } - Protocol::Github => self.connect_github(id, epoch, p, prompt), - Protocol::Irc => self.connect_irc(id, epoch, p, prompt), - Protocol::Vnc => self.connect_vnc(id, epoch, p, prompt), - Protocol::Rdp => self.connect_rdp(id, epoch, p, prompt), - Protocol::Local => err("unsupported protocol \"local\"".into()), - } - } - - /// Callbacks for terminal-style clients: output to the UI, recording and login scripts. - fn terminal_callbacks(self: &Arc, id: &str, epoch: u64) -> Callbacks { - let login = self.sessions.lock().get(id).map(|s| s.login.clone()).unwrap_or_default(); - let (me, sid) = (self.clone(), id.to_string()); - let on_data = Arc::new(move |d: &[u8]| { - me.emit(Event::Data { session: sid.clone(), data: d.to_vec() }); - let rec = me.sessions.lock().get(&sid).and_then(|s| s.recorder.clone()); - if let Some(r) = rec { - r.write_output(d); - } - let mut l = login.lock(); - if l.active { - l.buf.extend_from_slice(d); - if l.buf.len() > 16384 { - let cut = l.buf.len() - 16384; - l.buf.drain(..cut); - } - } - }); - let (me, sid) = (self.clone(), id.to_string()); - let on_error = Arc::new(move |e: String| { - let ok = me - .with(&sid, |s| { - if s.epoch != epoch { - return false; - } - s.info.state = SessionState::Error; - s.info.error = e.clone(); - true - }) - .unwrap_or(false); - if ok { - me.emit_state(&sid, SessionState::Error, &e); - } - }); - let (me, sid) = (self.clone(), id.to_string()); - let on_close = Arc::new(move || { - let me = me.clone(); - let sid = sid.clone(); - // Close callbacks can fire inside a client's own lock; handle them off-thread. - std::thread::spawn(move || me.handle_closed(&sid, epoch)); - }); - Callbacks { on_data, on_error, on_close } - } - - // --- credentials -------------------------------------------------------------------- - - /// Resolves a credential per its SecretMode (Go's buildSecretCallback): saved values come from - /// the keychain then the vault (unless the server just rejected them), and newly typed ones - /// are saved once the connection succeeds; session-only values are asked once per session and - /// reused on reconnects (so e.g. a NickServ password entered once keeps identifying after a - /// drop). - pub fn secret_fn(&self, session: &str, profile_id: &str, kind: SecretKind, mode: SecretMode, prompt: Prompt) -> SecretFn { - let key = kind.key(profile_id); - let (secrets, store) = (self.secrets.clone(), self.store.clone()); - let (pid, kind) = (profile_id.to_string(), kind.as_str()); - let state: SessionSecrets = self.sessions.lock().get(session).map(|s| s.session_secrets.clone()).unwrap_or_default(); - Arc::new(move || { - if mode == SecretMode::Saved { - let mut st = state.lock(); - if let Some(v) = st.pending.get(&key) { - return Ok(v.to_string()); - } - if !st.rejected.remove(&key) - && let Some(v) = secrets.get(&key) - { - st.used.insert(key.clone()); - store.add_audit("secret.access", &pid, "", &format!("Accessed {kind} credential")); - return Ok(v); - } - } - if mode == SecretMode::SessionOnly - && let Some(v) = state.lock().session_only.get(&key).map(|v| v.to_string()) - { - return Ok(v); - } - let v = prompt(&format!("Enter {kind}:"))?; - let mut st = state.lock(); - match mode { - SecretMode::Saved => { - st.pending.insert(key.clone(), Zeroizing::new(v.clone())); - } - SecretMode::SessionOnly => { - st.session_only.insert(key.clone(), Zeroizing::new(v.clone())); - } - SecretMode::AskEveryTime => {} - } - Ok(v) - }) - } - - /// Blocks until the user answers a credential prompt (5 minutes, or cancel). - pub fn prompt(&self, session: &str, title: &str, message: &str, timeout: Duration) -> Result { - let (tx, rx) = mpsc::channel(); - self.emit(Event::SecretPrompt { session: session.into(), title: title.into(), message: message.into(), reply: tx }); - match rx.recv_timeout(timeout) { - Ok(v) => Ok(v), - Err(mpsc::RecvTimeoutError::Timeout) => err("secret prompt timed out".into()), - Err(_) => err("user cancelled secret prompt".into()), - } - } - - /// The standard interactive prompt for a session's credentials. - pub fn prompt_for(self: &Arc, session: &str, title: &str, timeout: Duration) -> Prompt { - let (me, sid, title) = (self.clone(), session.to_string(), title.to_string()); - Arc::new(move |msg| me.prompt(&sid, &title, msg, timeout)) - } - - /// Host-key policy check against the known-hosts table (Go's HostKeyVerifier). Under the - /// `ask` policy both unknown and changed keys go to the user, who can reject, accept once - /// (connect without touching the record) or accept and save/update the record. - pub fn host_key_check(self: &Arc, session: &str, policy: HostKeyPolicy) -> HostKeyCheck { - let (me, sid) = (self.clone(), session.to_string()); - Arc::new(move |k: &HostKeyInfo| { - let stored = me.store.get_known_host(&k.host, k.port as i64).map_err(|e| format!("failed to query known hosts: {e}"))?; - let save = || { - let n = now(); - me.store - .save_known_host(&KnownHost { - id: String::new(), - host: k.host.clone(), - port: k.port as i64, - key_type: k.key_type.clone(), - fingerprint: k.fingerprint.clone(), - public_key: k.public_key_b64.clone(), - first_seen: n, - last_seen: n, - trusted: true, - }) - .map_err(|e| e.to_string()) - }; - let touch = |mut s: KnownHost| { - s.last_seen = now(); - let _ = me.store.save_known_host(&s); - Ok(()) - }; - let changed = |s: &KnownHost| { - format!("host key for {} has changed! stored fingerprint: {}, received: {} — possible MITM attack", k.host, s.fingerprint, k.fingerprint) - }; - let ask = |stored: Option<&KnownHost>| -> std::result::Result { - let (tx, rx) = mpsc::channel(); - me.emit(Event::HostKeyVerify { - session: sid.clone(), - host: k.host.clone(), - key_type: k.key_type.clone(), - fingerprint: k.fingerprint.clone(), - stored: stored.map(|s| s.fingerprint.clone()), - reply: tx, - }); - match rx.recv_timeout(Duration::from_secs(60)) { - Ok(d) => Ok(d), - Err(mpsc::RecvTimeoutError::Disconnected) => Ok(HostKeyDecision::Reject), - Err(_) => Err("host key prompt failed: host key prompt timed out waiting for user response".into()), - } - }; - match (policy, stored) { - (HostKeyPolicy::Insecure, None) | (HostKeyPolicy::AcceptNew, None) => save(), - (HostKeyPolicy::Insecure, Some(s)) => touch(s), - (HostKeyPolicy::Strict, None) => Err(format!("host key for {}:{} is not known (policy: strict)", k.host, k.port)), - (HostKeyPolicy::Ask, None) => match ask(None)? { - HostKeyDecision::AcceptAndSave => save(), - HostKeyDecision::AcceptOnce => Ok(()), - HostKeyDecision::Reject => Err(format!("host key for {}:{} rejected by user", k.host, k.port)), - }, - (_, Some(s)) if s.fingerprint == k.fingerprint => touch(s), - (HostKeyPolicy::Ask, Some(s)) => match ask(Some(&s))? { - HostKeyDecision::AcceptAndSave => { - me.store.delete_known_host(&s.id).map_err(|e| e.to_string())?; - save()?; - me.store.add_audit( - "hostkey.update", - "", - &sid, - &format!("Replaced host key for {}:{} ({} -> {})", k.host, k.port, s.fingerprint, k.fingerprint), - ); - Ok(()) - } - HostKeyDecision::AcceptOnce => { - me.store.add_audit( - "hostkey.accept-once", - "", - &sid, - &format!("Accepted changed host key for {}:{} once ({})", k.host, k.port, k.fingerprint), - ); - Ok(()) - } - HostKeyDecision::Reject => Err(format!("{} (rejected by user)", changed(&s))), - }, - (_, Some(s)) => Err(changed(&s)), - } - }) - } - - // --- per-protocol connect ----------------------------------------------------------- - - fn connect_ssh(self: &Arc, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result { - let mut p = p.clone(); - let sc = p.ssh.get_or_insert_with(SshConfig::defaults).clone(); - let password = self.secret_fn(id, &p.id, SecretKind::Password, sc.secret_mode, prompt.clone()); - let passphrase = self.secret_fn(id, &p.id, SecretKind::Passphrase, sc.key_secret_mode, prompt); - let c = SshClient::new(&p, self.terminal_callbacks(id, epoch)); - let stored = |k: SecretKind| self.secrets.get(&k.key(&p.id)).unwrap_or_default(); - c.set_stored_secrets(stored(SecretKind::Totp), stored(SecretKind::ProxyPassword)); - c.connect(self.host_key_check(id, sc.host_key_policy), Some(password), Some(passphrase))?; - for pf in sc.port_forwards.iter().filter(|f| f.enabled) { - match c.start_forward(pf.clone()) { - Ok(()) => self.emit(Event::ForwardStarted { session: id.into(), forward: pf.clone() }), - Err(e) => self.emit(Event::ForwardError { session: id.into(), forward_id: pf.id.clone(), label: pf.label.clone(), error: e.to_string() }), - } - } - self.start_login_scripts(id, epoch, sc.login_scripts.clone()); - Ok(Client::Ssh(c)) - } - - /// An SSH connection used only to tunnel another protocol (IRC, VNC). - #[allow(clippy::too_many_arguments)] - fn tunnel( - self: &Arc, - id: &str, - profile_id: &str, - host: &str, - port: i64, - user: &str, - method: &str, - key: &str, - agent: bool, - mode: SecretMode, - kind: &str, - prompt: Prompt, - ) -> Result> { - let mut tp = Profile::new(Protocol::Ssh); - tp.host = host.into(); - tp.port = if port == 0 { 22 } else { port }; - tp.username = user.into(); - let c = tp.ssh.as_mut().unwrap(); - c.auth_methods = vec![if method.is_empty() { "publickey".into() } else { method.into() }]; - c.private_key_path = key.into(); - c.agent_auth = agent; - let (pw_kind, pp_kind) = match kind { - "vnc" => (SecretKind::VncSshPassword, SecretKind::VncSshPassphrase), - "rdp" => (SecretKind::RdpSshPassword, SecretKind::RdpSshPassphrase), - _ => (SecretKind::IrcSshPassword, SecretKind::IrcSshPassphrase), - }; - let password = (method == "password").then(|| self.secret_fn(id, profile_id, pw_kind, mode, prompt.clone())); - let passphrase = self.secret_fn(id, profile_id, pp_kind, mode, prompt); - let t = SshClient::new(&tp, Callbacks::noop()); - t.connect_tunnel_only(self.host_key_check(id, HostKeyPolicy::Ask), password, Some(passphrase))?; - Ok(t) - } - - fn connect_github(self: &Arc, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result { - let gh = p.github.clone().ok_or_else(|| format!("GitHub configuration missing on profile {:?}", p.id))?; - let token = self.secret_fn(id, &p.id, SecretKind::GithubToken, gh.secret_mode, prompt.clone())().map_err(|e| format!("obtaining GitHub token: {e}"))?; - if token.is_empty() { - return err("GitHub token cannot be empty".into()); - } - let mut client = github::Client::new(&gh.base_url, &token); - let login = match client.validate_token() { - Ok(l) => l, - Err(e) => { - let msg = e.to_string().to_lowercase(); - let auth = ["token validation failed", "authentication failed", "http 401", "http 403"].iter().any(|k| msg.contains(k)); - let retry = auth.then(|| prompt("GitHub token did not work. Please enter a valid token:").ok()).flatten().filter(|t| !t.is_empty()); - let Some(t) = retry else { return err(format!("GitHub authentication failed: {e}")) }; - if gh.secret_mode == SecretMode::Saved { - let _ = self.with(id, |s| s.session_secrets.lock().pending.insert(SecretKind::GithubToken.key(&p.id), Zeroizing::new(t.clone()))); - } - client = github::Client::new(&gh.base_url, &t); - client.validate_token().map_err(|e| format!("GitHub authentication failed: {e}"))? - } - }; - let client = Arc::new(client); - if !self.is_current(id, epoch) { - return err(format!("session {id:?} was closed while connecting")); - } - let fetch = { - let (me, sid, client) = (self.clone(), id.to_string(), client.clone()); - move |status: &str| { - me.emit(Event::GithubStatus { session: sid.clone(), status: status.into() }); - let (me2, sid2) = (me.clone(), sid.clone()); - client.fetch_repos_with_progress(move |done, total| { - if total > 0 { - me2.emit(Event::GithubStatus { session: sid2.clone(), status: format!("Loading commit info ({done}/{total})") }); - } - }) - } - }; - let repos = fetch("Loading repositories...").map_err(|e| format!("fetching repositories: {e}"))?; - self.emit(Event::GithubRepos { session: id.into(), login: login.clone(), repos }); - let interval = Duration::from_secs(if gh.refresh_interval_secs < 60 { 300 } else { gh.refresh_interval_secs as u64 }); - let stop = Arc::new(AtomicBool::new(false)); - let (me, sid, stop2) = (self.clone(), id.to_string(), stop.clone()); - std::thread::spawn(move || { - loop { - let deadline = std::time::Instant::now() + interval; - while std::time::Instant::now() < deadline { - if stop2.load(Ordering::SeqCst) { - return; - } - std::thread::sleep(Duration::from_millis(500)); - } - match fetch("Refreshing repositories...") { - Ok(repos) => me.emit(Event::GithubRepos { session: sid.clone(), login: login.clone(), repos }), - Err(e) => me.emit(Event::GithubError { session: sid.clone(), error: e.to_string() }), - } - } - }); - Ok(Client::Github(client, stop)) - } - - fn connect_irc(self: &Arc, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result { - let ic = p.irc.clone().ok_or("IRC configuration missing")?; - let server_pass = if ic.server_password_required { - self.secret_fn(id, &p.id, SecretKind::IrcServerPassword, ic.server_password_mode, prompt.clone())() - .map_err(|e| format!("IRC server password: {e}"))? - } else { - String::new() - }; - let ns_pass = if ic.nick_serv_identify { - self.secret_fn(id, &p.id, SecretKind::IrcNickServPassword, ic.nick_serv_mode, prompt.clone())().map_err(|e| format!("NickServ password: {e}"))? - } else { - String::new() - }; - let mut rooms = ic.auto_join_rooms.clone(); - if !p.id.is_empty() { - let last = self.store.irc_last_rooms(&p.id).map_err(|e| format!("loading remembered IRC rooms: {e}"))?; - rooms = crate::storage::dedup_rooms(&[&rooms, &last]); - } - let tunnel = if ic.ssh_tunnel { - Some(self.tunnel( - id, - &p.id, - &ic.ssh_host, - ic.ssh_port, - &ic.ssh_username, - &ic.ssh_auth_method, - &ic.ssh_private_key, - ic.ssh_agent_auth, - ic.ssh_secret_mode, - "irc", - prompt, - )?) - } else { - None - }; - let cb = self.terminal_callbacks(id, epoch); - let (me, sid) = (self.clone(), id.to_string()); - let on_event: irc::EventFn = Arc::new(move |e| me.emit(Event::Irc { session: sid.clone(), event: e })); - let (me, sid, pid) = (self.clone(), id.to_string(), p.id.clone()); - let on_rooms: irc::RoomsChangedFn = Arc::new(move |rooms: Vec| { - if pid.is_empty() { - return; - } - if let Err(e) = me.store.save_irc_last_rooms(&pid, &rooms) { - me.emit(Event::Irc { - session: sid.clone(), - event: irc::Event { - kind: "system".into(), - target: irc::SERVER_TARGET.into(), - text: format!("Could not remember open IRC rooms: {e}"), - timestamp: chrono::Utc::now().timestamp_millis(), - ..Default::default() - }, - }); - } - }); - let connector: Option = tunnel.clone().map(|t| { - let (host, port) = (p.host.clone(), p.port as u16); - Arc::new(move || { - let s = t.open_tunnel(&host, port)?; - s.set_read_timeout(Some(Duration::from_millis(50)))?; - Ok(Box::new(s) as Box) - }) as irc::Connector - }); - let cfg = irc::Config { - server: p.host.clone(), - port: p.port as u16, - nick: ic.nick.clone(), - user: if p.username.is_empty() { ic.nick.clone() } else { p.username.clone() }, - real_name: ic.real_name.clone(), - use_tls: ic.use_tls, - server_pass, - auto_join_rooms: rooms, - nickserv_identify: ic.nick_serv_identify, - nickserv_account: ic.nick_serv_account.clone(), - nickserv_password: ns_pass, - connector, - connect_timeout: Duration::from_secs(p.connect_timeout.max(1) as u64), - on_event, - on_error: cb.on_error, - on_close: cb.on_close, - on_rooms_changed: on_rooms, - }; - let c = irc::Client::new(cfg); - if let Err(e) = c.connect() { - if let Some(t) = tunnel { - t.close(); - } - return Err(e); - } - Ok(Client::Irc(c, tunnel)) - } - - fn connect_vnc(self: &Arc, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result { - let vc = p.vnc.clone().ok_or("VNC configuration missing")?; - let host = if vc.vnc_host.is_empty() { p.host.clone() } else { vc.vnc_host.clone() }; - let port = if vc.vnc_port == 0 { 5900 } else { vc.vnc_port } as u16; - let tunnel = if vc.ssh_tunnel { - Some(self.tunnel( - id, - &p.id, - &vc.ssh_host, - vc.ssh_port, - &vc.ssh_username, - &vc.ssh_auth_method, - &vc.ssh_private_key, - vc.ssh_agent_auth, - vc.ssh_secret_mode, - "vnc", - prompt.clone(), - )?) - } else { - None - }; - let password = self.secret_fn(id, &p.id, SecretKind::VncPassword, vc.secret_mode, prompt); - let cb = self.terminal_callbacks(id, epoch); - let (me, sid) = (self.clone(), id.to_string()); - let (me2, sid2) = (self.clone(), id.to_string()); - let clip: clients::vnc::ClipboardFn = Arc::new(move |text| me2.emit(Event::VncClipboard { session: sid2.clone(), text })); - let c = connect_vnc_client( - &host, - port, - &vc, - p.connect_timeout, - password, - tunnel.clone(), - cb, - Arc::new(move || me.emit(Event::DesktopFrame { session: sid.clone() })), - clip, - ); - match c { - Ok(c) => { - self.emit(Event::DesktopReady { session: id.into() }); - Ok(Client::Vnc(c, tunnel)) - } - Err(e) => { - if let Some(t) = tunnel { - t.close(); - } - Err(e) - } - } - } - - fn connect_rdp(self: &Arc, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result { - let rc = p.rdp.clone().unwrap_or_else(RdpConfig::defaults); - let port = if p.port == 0 { DEFAULT_RDP_PORT } else { p.port as u16 }; - let tunnel = if rc.ssh_tunnel { - Some(self.tunnel( - id, - &p.id, - &rc.ssh_host, - rc.ssh_port, - &rc.ssh_username, - &rc.ssh_auth_method, - &rc.ssh_private_key, - rc.ssh_agent_auth, - rc.ssh_secret_mode, - "rdp", - prompt.clone(), - )?) - } else { - None - }; - let r = self.rdp_client(id, epoch, p, &rc, port, tunnel.clone(), prompt); - match r { - Ok(c) => { - self.emit(Event::DesktopReady { session: id.into() }); - Ok(Client::Rdp(c, tunnel)) - } - Err(e) => { - if let Some(t) = tunnel { - t.close(); - } - Err(e) - } - } - } - - #[allow(clippy::too_many_arguments)] - fn rdp_client( - self: &Arc, - id: &str, - epoch: u64, - p: &Profile, - rc: &RdpConfig, - port: u16, - tunnel: Option>, - prompt: Prompt, - ) -> Result> { - let timeout = Duration::from_secs(p.connect_timeout.max(1) as u64); - let host = p.host.clone(); - let dial: clients::rdp::Dial = match tunnel { - Some(t) => { - let h = host.clone(); - Arc::new(move || t.open_tunnel(&h, port)) - } - None => { - let proxy = clients::proxy::Proxy { - kind: rc.proxy_type.clone(), - host: rc.proxy_host.clone(), - port: rc.proxy_port as u16, - username: rc.proxy_username.clone(), - password: self.secrets.get(&SecretKind::ProxyPassword.key(&p.id)).unwrap_or_default(), - }; - let h = host.clone(); - Arc::new(move || proxy.connect(&h, port, timeout)) - } - }; - let check = self.host_key_check(id, rc.host_key_policy); - let h = host.clone(); - let check_cert: clients::rdp::CertCheck = Arc::new(move |fp: &str| { - check(&HostKeyInfo { host: h.clone(), port, key_type: "rdp-tls".into(), fingerprint: fp.into(), public_key_b64: String::new() }) - }); - // A 0×0 configured size follows the tab, which reports its pixel size like a terminal. - let (width, height) = match (rc.width, rc.height) { - (w, h) if w > 0 && h > 0 => (w as u16, h as u16), - _ => self.with(id, |s| s.pending_resize).ok().flatten().unwrap_or((1280, 800)), - }; - let password = self.secret_fn(id, &p.id, SecretKind::RdpPassword, rc.secret_mode, prompt)()?; - let cb = self.terminal_callbacks(id, epoch); - let (me, sid) = (self.clone(), id.to_string()); - let c = clients::rdp::Client::new(clients::rdp::Config { - host, - username: p.username.clone(), - domain: rc.domain.clone(), - password, - width, - height, - allow_tls_only: rc.allow_tls_only, - dial, - check_cert, - connect_timeout: timeout, - on_frame: Arc::new(move || me.emit(Event::DesktopFrame { session: sid.clone() })), - on_error: cb.on_error, - on_close: cb.on_close, - }); - c.connect()?; - Ok(c) - } - - // --- local shell -------------------------------------------------------------------- - - pub fn connect_local(self: &Arc, shell: &str) -> String { - let id = new_id(); - let info = SessionInfo { - id: id.clone(), - profile_id: String::new(), - profile_name: "Local Machine".into(), - protocol: Protocol::Local, - host: "localhost".into(), - port: 0, - state: SessionState::Connecting, - tab_name: "Local".into(), - pinned: false, - connected_at: now(), - error: String::new(), - }; - let epoch = self.new_session(info, None, shell.into(), SessionSecrets::default()); - self.emit_state(&id, SessionState::Connecting, ""); - let me = self.clone(); - let id2 = id.clone(); - std::thread::spawn(move || me.start_local(&id2, epoch, 80, 24)); - id - } - - /// Starts a fresh shell in an exited local tab, keeping its scrollback and last directory. - pub fn restart_local(self: &Arc, id: &str, cols: u16, rows: u16) -> Result<()> { - let epoch = self.with(id, |s| { - if s.info.protocol != Protocol::Local { - return Err(format!("sessions: session {id:?} is not local")); - } - if matches!(s.client, Client::Local(_)) || matches!(s.info.state, SessionState::Connecting | SessionState::Connected) { - return Err(format!("sessions: local session {id:?} is still running")); - } - s.info.state = SessionState::Connecting; - s.info.error.clear(); - Ok(s.epoch) - })??; - self.emit_state(id, SessionState::Connecting, ""); - let (me, id2) = (self.clone(), id.to_string()); - std::thread::spawn(move || me.start_local(&id2, epoch, cols, rows)); - Ok(()) - } - - fn start_local(self: &Arc, id: &str, epoch: u64, cols: u16, rows: u16) { - let Ok((shell, cwd, pending)) = self.with(id, |s| (s.local_shell.clone(), s.local_cwd.clone(), s.pending_resize.take())) else { return }; - let (cols, rows) = pending.unwrap_or((cols, rows)); - let mut cb = self.terminal_callbacks(id, epoch); - // A local shell exiting keeps its tab ("press enter to restart"). - let (me, sid) = (self.clone(), id.to_string()); - cb.on_close = Arc::new(move || { - let (me, sid) = (me.clone(), sid.clone()); - std::thread::spawn(move || me.local_closed(&sid, epoch)); - }); - let c = LocalShell::new(&shell, cwd, cols, rows, cb); - let r = c.start().map(|_| Client::Local(c.clone())); - self.finish_connect(id, epoch, r, |me| me.store.add_audit("connect", "", id, "Connected via local to localhost")); - } - - fn local_closed(self: &Arc, id: &str, epoch: u64) { - let cwd = self.with(id, |s| { - if s.epoch != epoch || s.closed { - return None; - } - match std::mem::replace(&mut s.client, Client::None) { - Client::Local(c) => Some(c.working_directory()), - other => { - s.client = other; - None - } - } - }); - if let Ok(Some(cwd)) = cwd { - let _ = self.with(id, |s| s.local_cwd = Some(cwd)); - self.handle_closed(id, epoch); - } - } - - // --- lifecycle ---------------------------------------------------------------------- - - /// Explicit close: the tab is gone, so no reconnect. - pub fn disconnect(&self, id: &str) -> Result<()> { - let s = self.sessions.lock().remove(id).ok_or_else(|| format!("sessions: session {id:?} not found"))?; - self.close_session(s); - self.store.add_audit("disconnect", "", id, "Session disconnected"); - Ok(()) - } - - /// Closes and re-opens a session from its profile, keeping the tab name and pin; returns the - /// new session id. - pub fn reconnect(self: &Arc, id: &str) -> Result { - let s = self.sessions.lock().remove(id).ok_or_else(|| format!("sessions: session {id:?} not found"))?; - let (profile, tab, pinned, proto, shell) = (s.profile.clone(), s.info.tab_name.clone(), s.info.pinned, s.info.protocol, s.local_shell.clone()); - let session_secrets = s.session_secrets.clone(); - self.close_session(s); - let new = match (proto, profile) { - (Protocol::Local, _) => self.connect_local(&shell), - (_, Some(p)) => self.connect_with(p, session_secrets), - _ => return err("sessions: nothing to reconnect".into()), - }; - let _ = self.with(&new, |s| { - s.info.tab_name = tab; - s.info.pinned = pinned; - }); - Ok(new) - } - - fn close_session(&self, mut s: Session) { - s.closed = true; - if let Some(c) = s.cancel_reconnect.take() { - c.store(true, Ordering::SeqCst); - } - if let Some(r) = s.recorder.take() { - let _ = r.close(); - } - s.client.close(); - } - - fn handle_closed(self: &Arc, id: &str, epoch: u64) { - let profile = { - let mut map = self.sessions.lock(); - let Some(s) = map.get_mut(id).filter(|s| s.epoch == epoch && !s.closed) else { return }; - s.info.state = SessionState::Disconnected; - if !matches!(s.client, Client::Local(_)) { - s.client = Client::None; - } - s.profile.clone() - }; - self.store.add_audit("disconnect", "", id, "Session disconnected"); - if let Some(p) = profile.filter(|p| p.auto_reconnect()) { - self.schedule_reconnect(id, epoch, p); - return; - } - self.emit(Event::Closed { session: id.into() }); - self.emit_state(id, SessionState::Disconnected, ""); - } - - /// Exponential backoff 1, 2, 4 … 30 s, up to the profile's attempt limit. - fn schedule_reconnect(self: &Arc, id: &str, epoch: u64, p: Profile) { - let max = p.max_reconnect_tries(); - let cancel = Arc::new(AtomicBool::new(false)); - let attempt = self.with(id, |s| { - s.reconnect_count += 1; - if s.reconnect_count > max { - s.info.state = SessionState::Disconnected; - return None; - } - s.info.state = SessionState::Reconnecting; - s.info.error.clear(); - if let Some(c) = s.cancel_reconnect.replace(cancel.clone()) { - c.store(true, Ordering::SeqCst); - } - Some(s.reconnect_count) - }); - let Ok(attempt) = attempt else { return }; - let Some(attempt) = attempt else { - self.emit(Event::Closed { session: id.into() }); - self.emit_state(id, SessionState::Disconnected, "auto-reconnect failed after maximum attempts"); - return; - }; - let delay = (1u64 << (attempt - 1).min(5)).min(30); - self.emit(Event::State { - session: id.into(), - state: SessionState::Reconnecting, - error: String::new(), - retry: Some(Retry { attempt, max, delay_secs: delay }), - }); - let (me, id) = (self.clone(), id.to_string()); - std::thread::spawn(move || { - let deadline = std::time::Instant::now() + Duration::from_secs(delay); - while std::time::Instant::now() < deadline { - if cancel.load(Ordering::SeqCst) { - return; - } - std::thread::sleep(Duration::from_millis(100)); - } - let ok = me - .with(&id, |s| { - if s.epoch != epoch || s.closed || cancel.load(Ordering::SeqCst) { - return false; - } - s.client = Client::None; - s.info.state = SessionState::Connecting; - true - }) - .unwrap_or(false); - if !ok { - return; - } - me.emit_state(&id, SessionState::Connecting, ""); - let prompt = me.prompt_for(&id, "Reconnection Credentials", Duration::from_secs(300)); - match me.connect_protocol(&id, epoch, &p, prompt) { - Ok(c) => me.finish_connect(&id, epoch, Ok(c), |_| {}), - Err(e) => { - let current = me.with(&id, |s| { - if s.epoch != epoch || s.closed { - return false; - } - s.info.state = SessionState::Error; - s.info.error = e.to_string(); - attempt_failed(&s.session_secrets, &e.to_string()); - true - }); - if current.unwrap_or(false) { - me.emit_state(&id, SessionState::Error, &e.to_string()); - me.schedule_reconnect(&id, epoch, p); - } - } - } - }); - } - - pub fn cancel_reconnect(&self, id: &str) { - let found = self - .with(id, |s| { - if let Some(c) = s.cancel_reconnect.take() { - c.store(true, Ordering::SeqCst); - s.reconnect_count = 0; - s.info.state = SessionState::Disconnected; - } - }) - .is_ok(); - if found { - self.emit_state(id, SessionState::Disconnected, ""); - } - } - - pub fn close_all(&self) { - let all: Vec = self.sessions.lock().drain().map(|(_, s)| s).collect(); - for s in all { - self.close_session(s); - } - } - - // --- login scripts ------------------------------------------------------------------ - - fn start_login_scripts(self: &Arc, id: &str, epoch: u64, steps: Vec) { - if steps.is_empty() { - return; - } - let Ok(login) = self.with(id, |s| s.login.clone()) else { return }; - *login.lock() = LoginBuf { active: true, buf: vec![] }; - let (me, id) = (self.clone(), id.to_string()); - std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(500)); - for (i, step) in steps.iter().enumerate() { - // Wait until the connect has been recorded as Connected. - let mut waited = 0; - while me.with(&id, |s| s.info.state).ok() == Some(SessionState::Connecting) && waited < 100 { - std::thread::sleep(Duration::from_millis(50)); - waited += 1; - } - if !me.is_current(&id, epoch) || me.with(&id, |s| s.info.state).ok() != Some(SessionState::Connected) { - break; - } - match step.kind.as_str() { - "wait" => { - let timeout = Duration::from_secs(if step.timeout <= 0 { 10 } else { step.timeout as u64 }); - if !wait_for_output(&login, &step.value, timeout) { - me.emit(Event::Debug { - session: id.clone(), - message: format!("login script step {} timed out waiting for {:?}", i + 1, step.value), - }); - break; - } - } - "send" => { - if let Err(e) = me.write(&id, format!("{}\n", step.value).as_bytes()) { - me.emit(Event::Debug { session: id.clone(), message: format!("login script step {} failed to send: {e}", i + 1) }); - break; - } - std::thread::sleep(Duration::from_millis(200)); - } - _ => {} - } - } - *login.lock() = LoginBuf::default(); - }); - } - - // --- I/O ---------------------------------------------------------------------------- - - pub fn write(&self, id: &str, data: &[u8]) -> Result<()> { - let (client, rec) = self.with(id, |s| (s.client.clone(), s.recorder.clone()))?; - if let Some(r) = rec { - r.write_input(data); - } - match client { - Client::Ssh(c) => c.write(data), - Client::Telnet(c) => c.write(data), - Client::Raw(c) => c.write(data), - Client::Serial(c) => c.write(data), - Client::Local(c) => c.write(data), - _ => err(format!("sessions: no active client for session {id:?}")), - } - } - - fn resize_client(&self, c: &Client, cols: u16, rows: u16) -> Result<()> { - if cols == 0 || rows == 0 { - return Ok(()); - } - match c { - Client::Ssh(c) => c.resize(cols as u32, rows as u32), - Client::Telnet(c) => c.resize(cols, rows), - Client::Local(c) => c.resize(cols, rows), - // For a remote desktop the "size" is the tab's size in pixels. - Client::Rdp(c, _) => { - c.resize(cols, rows); - Ok(()) - } - _ => Ok(()), - } - } - - /// Resizes the remote PTY; before a client exists the size is kept and applied on connect. - pub fn resize(&self, id: &str, cols: u16, rows: u16) -> Result<()> { - let c = self.with(id, |s| { - if matches!(s.client, Client::None) { - s.pending_resize = Some((cols, rows)); - } - s.client.clone() - })?; - self.resize_client(&c, cols, rows) - } - - pub fn info(&self, id: &str) -> Option { - self.sessions.lock().get(id).map(|s| s.info.clone()) - } - - pub fn list(&self) -> Vec { - self.sessions.lock().values().map(|s| s.info.clone()).collect() - } - - pub fn profile(&self, id: &str) -> Option { - self.sessions.lock().get(id).and_then(|s| s.profile.clone()) - } - - pub fn rename(&self, id: &str, name: &str) -> Result<()> { - self.with(id, |s| s.info.tab_name = name.into()) - } - - pub fn pin(&self, id: &str, pinned: bool) -> Result<()> { - self.with(id, |s| s.info.pinned = pinned) - } - - pub fn send_break(&self, id: &str) -> Result<()> { - match self.client(id)? { - Client::Telnet(c) => c.send_break(), - Client::Serial(c) => c.send_break(), - _ => err(format!("sessions: session {id:?} does not support break")), - } - } - - pub fn set_serial_lines(&self, id: &str, dtr: bool, rts: bool) -> Result<()> { - match self.client(id)? { - Client::Serial(c) => c.set_lines(dtr, rts), - _ => err(format!("sessions: session {id:?} is not a serial connection")), - } - } - - // --- RAW ---------------------------------------------------------------------------- - - fn raw(&self, id: &str) -> Result> { - match self.client(id)? { - Client::Raw(c) => Ok(c), - _ => err(format!("sessions: session {id:?} is not a RAW connection")), - } - } - - pub fn raw_write_text(&self, id: &str, s: &str) -> Result<()> { - self.raw(id)?.write_text(s) - } - - pub fn raw_write_hex(&self, id: &str, s: &str) -> Result<()> { - self.raw(id)?.write_hex(s) - } - - pub fn raw_write_escaped(&self, id: &str, s: &str) -> Result<()> { - self.raw(id)?.write_escaped(s) - } - - pub fn raw_history(&self, id: &str) -> Result> { - Ok(self.raw(id)?.history()) - } - - pub fn raw_clear_history(&self, id: &str) -> Result<()> { - self.raw(id)?.clear_history(); - Ok(()) - } - - pub fn raw_save_capture(&self, id: &str, path: &std::path::Path) -> Result<()> { - self.raw(id)?.save_capture(path) - } - - // --- IRC ---------------------------------------------------------------------------- - - pub fn irc(&self, id: &str) -> Result> { - match self.client(id)? { - Client::Irc(c, _) => Ok(c), - _ => err(format!("sessions: session {id:?} is not an IRC connection")), - } - } - - // --- GitHub ------------------------------------------------------------------------- - - pub fn github(&self, id: &str) -> Result> { - match self.client(id)? { - Client::Github(c, _) => Ok(c), - _ => err(format!("sessions: session {id:?} is not a GitHub connection")), - } - } - - pub fn rdp(&self, id: &str) -> Result> { - match self.client(id)? { - Client::Rdp(c, _) => Ok(c), - _ => err(format!("sessions: session {id:?} is not an RDP connection")), - } - } - - pub fn vnc(&self, id: &str) -> Result> { - match self.client(id)? { - Client::Vnc(c, _) => Ok(c), - _ => err(format!("sessions: session {id:?} is not a VNC connection")), - } - } - - // --- SSH extras --------------------------------------------------------------------- - - pub fn ssh(&self, id: &str) -> Result> { - match self.client(id)? { - Client::Ssh(c) => Ok(c), - _ => err(format!("sessions: session {id:?} is not an SSH connection")), - } - } - - pub fn ping(&self, id: &str) -> Result { - self.ssh(id)?.ping() - } - - pub fn cwd(&self, id: &str) -> Result { - self.ssh(id)?.run_command("pwd") - } - - pub fn start_forward(&self, id: &str, rule: PortForward) -> Result<()> { - self.ssh(id)?.start_forward(rule.clone())?; - self.emit(Event::ForwardStarted { session: id.into(), forward: rule }); - Ok(()) - } - - pub fn stop_forward(&self, id: &str, fid: &str) -> Result<()> { - self.ssh(id)?.stop_forward(fid)?; - self.emit(Event::ForwardStopped { session: id.into(), forward_id: fid.into() }); - Ok(()) - } - - pub fn forwards(&self, id: &str) -> Result> { - Ok(self.ssh(id)?.active_forwards()) - } - - pub fn sftp_download(&self, id: &str, remote: &str, local: &std::path::Path) -> Result<()> { - let s = self.ssh(id)?.sftp()?; - clients::sftp::download(&s, remote, local, &|p| self.emit(Event::SftpProgress { session: id.into(), progress: p })) - } - - pub fn sftp_upload(&self, id: &str, local: &std::path::Path, remote: &str) -> Result<()> { - let s = self.ssh(id)?.sftp()?; - clients::sftp::upload(&s, local, remote, &|p| self.emit(Event::SftpProgress { session: id.into(), progress: p })) - } - - // --- recording ---------------------------------------------------------------------- - - pub fn start_recording(&self, id: &str, cols: u16, rows: u16) -> Result<()> { - let (exists, title) = self.with(id, |s| (s.recorder.is_some(), format!("{} — {}", s.info.profile_name, s.info.host)))?; - if exists { - return err(format!("sessions: session {id:?} is already being recorded")); - } - let r = Arc::new( - Recorder::new(self.recordings.dir(), id, &title, cols as i64, rows as i64).map_err(|e| format!("sessions: failed to start recording: {e}"))?, - ); - self.with(id, |s| s.recorder = Some(r))?; - self.emit(Event::RecordingStarted { session: id.into() }); - Ok(()) - } - - pub fn stop_recording(&self, id: &str) -> Result<()> { - let r = self.with(id, |s| s.recorder.take())?.ok_or_else(|| format!("sessions: session {id:?} is not being recorded"))?; - r.close().map_err(|e| format!("sessions: failed to stop recording: {e}"))?; - self.emit(Event::RecordingStopped { session: id.into(), duration: r.duration(), events: r.event_count() }); - Ok(()) - } - - pub fn is_recording(&self, id: &str) -> bool { - self.with(id, |s| s.recorder.is_some()).unwrap_or(false) - } - - pub fn recordings(&self) -> &recording::Store { - &self.recordings - } -} - -/// Builds and connects a VNC client from a profile's settings, optionally through an SSH tunnel. -#[allow(clippy::too_many_arguments)] -fn connect_vnc_client( - host: &str, - port: u16, - vc: &VncConfig, - timeout: i64, - password: SecretFn, - tunnel: Option>, - cb: Callbacks, - on_frame: Arc, - on_clipboard: clients::vnc::ClipboardFn, -) -> Result> { - let connector: Option = tunnel.map(|t| { - let host = host.to_string(); - Arc::new(move || { - let s = t.open_tunnel(&host, port)?; - s.set_read_timeout(Some(Duration::from_millis(50)))?; - Ok(Box::new(s) as Box) - }) as irc::Connector - }); - let mut cfg = clients::vnc::Config { - host: host.into(), - port, - password: None, - shared: vc.shared_session, - view_only: vc.view_only, - quality: vc.quality.clamp(0, 9) as u8, - connector, - connect_timeout: Duration::from_secs(timeout.max(1) as u64), - on_frame, - on_clipboard, - on_error: cb.on_error, - on_close: cb.on_close, - ..Default::default() - }; - let c = clients::vnc::Client::new(cfg.clone()); - match c.connect() { - Ok(()) => Ok(c), - // Ask for the password only when the server wants one. - Err(e) if e.to_string().to_lowercase().contains("password") => { - cfg.password = Some(password()?); - let c = clients::vnc::Client::new(cfg); - c.connect()?; - Ok(c) - } - Err(e) => Err(e), - } -} - -fn wait_for_output(login: &Mutex, pattern: &str, timeout: Duration) -> bool { - if pattern.is_empty() { - return true; - } - let deadline = std::time::Instant::now() + timeout; - while std::time::Instant::now() < deadline { - { - let mut l = login.lock(); - let text = String::from_utf8_lossy(&l.buf).into_owned(); - if let Some(i) = text.find(pattern) { - let consumed = text[..i + pattern.len()].len(); - let n = consumed.min(l.buf.len()); - l.buf.drain(..n); - return true; - } - } - std::thread::sleep(Duration::from_millis(100)); - } - false -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::secrets::{Keychain, Secrets, Vault}; - use std::sync::atomic::AtomicUsize; - - fn manager(name: &str) -> (Arc, PathBuf) { - let dir = std::env::temp_dir().join(format!("nexus-secretfn-{name}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let store = Arc::new(Store::open(&dir.join("nexus.db")).unwrap()); - let secrets = Arc::new(Secrets { vault: Vault::new(dir.join("vault")), keychain: Keychain::new("NexusTerminalTest"), keychain_ok: false }); - (Manager::new(store, secrets, dir.join("recordings")), dir) - } - - fn counting_prompt(n: Arc) -> Prompt { - Arc::new(move |_| { - n.fetch_add(1, Ordering::SeqCst); - Ok("hunter2".into()) - }) - } - - #[test] - fn saved_credentials_are_stored_only_after_success_and_reasked_after_rejection() { - let (m, dir) = manager("saved"); - let warnings: Arc>> = Arc::default(); - let w = warnings.clone(); - m.set_sink(Arc::new(move |e| { - if let Event::Warning { message, .. } = e { - w.lock().push(message); - } - })); - let p = Profile { host: "127.0.0.1".into(), port: 1, ..Profile::new(Protocol::Raw) }; - let key = SecretKind::Password.key(&p.id); - let info = |id: &str| SessionInfo { - id: id.into(), - profile_id: p.id.clone(), - profile_name: String::new(), - protocol: Protocol::Raw, - host: p.host.clone(), - port: p.port, - state: SessionState::Connecting, - tab_name: String::new(), - pinned: false, - connected_at: now(), - error: String::new(), - }; - let asked = Arc::new(AtomicUsize::new(0)); - let resolve = |m: &Arc, id: &str| m.secret_fn(id, &p.id, SecretKind::Password, SecretMode::Saved, counting_prompt(asked.clone()))().unwrap(); - - // Locked store: the credential can't be saved, and the user is told. - let e = m.new_session(info("s0"), Some(p.clone()), String::new(), SessionSecrets::default()); - resolve(&m, "s0"); - m.finish_connect("s0", e, Ok(Client::None), |_| {}); - assert_eq!(warnings.lock().len(), 1); - - m.secrets.vault.initialise("pw").unwrap(); - let e = m.new_session(info("s1"), Some(p.clone()), String::new(), SessionSecrets::default()); - // A typed password the server rejects is never stored. - resolve(&m, "s1"); - m.finish_connect("s1", e, Err("ssh: unable to authenticate".into()), |_| {}); - assert!(m.secrets.get(&key).is_none()); - // Typed again and accepted: stored. - resolve(&m, "s1"); - m.finish_connect("s1", e, Ok(Client::None), |_| {}); - assert_eq!(m.secrets.get(&key).as_deref(), Some("hunter2")); - assert_eq!(asked.load(Ordering::SeqCst), 3); - - // The saved value is reused; once the server rejects it, the next attempt asks again. - let e = m.new_session(info("s2"), Some(p.clone()), String::new(), SessionSecrets::default()); - resolve(&m, "s2"); - assert_eq!(asked.load(Ordering::SeqCst), 3); - m.finish_connect("s2", e, Err("VNC authentication failed".into()), |_| {}); - resolve(&m, "s2"); - assert_eq!(asked.load(Ordering::SeqCst), 4); - // A network error doesn't count as a rejection. - m.finish_connect("s2", e, Err("connection refused".into()), |_| {}); - resolve(&m, "s2"); - assert_eq!(asked.load(Ordering::SeqCst), 4); - m.close_all(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn session_only_secrets_survive_reconnects() { - let (m, dir) = manager("cache"); - let p = Profile { host: "127.0.0.1".into(), port: 1, ..Profile::new(Protocol::Raw) }; - let info = SessionInfo { - id: "s1".into(), - profile_id: p.id.clone(), - profile_name: String::new(), - protocol: Protocol::Raw, - host: p.host.clone(), - port: p.port, - state: SessionState::Connecting, - tab_name: String::new(), - pinned: false, - connected_at: now(), - error: String::new(), - }; - m.new_session(info, Some(p.clone()), String::new(), SessionSecrets::default()); - let asked = Arc::new(AtomicUsize::new(0)); - // Two separate resolutions (as two connects of the same session make) prompt once. - for _ in 0..2 { - let f = m.secret_fn("s1", &p.id, SecretKind::IrcNickServPassword, SecretMode::SessionOnly, counting_prompt(asked.clone())); - assert_eq!(f().unwrap(), "hunter2"); - } - assert_eq!(asked.load(Ordering::SeqCst), 1); - // Ask-every-time still prompts each time. - let f = m.secret_fn("s1", &p.id, SecretKind::IrcNickServPassword, SecretMode::AskEveryTime, counting_prompt(asked.clone())); - f().unwrap(); - assert_eq!(asked.load(Ordering::SeqCst), 2); - // An explicit reconnect carries the cache to the new session id. - let new = m.reconnect("s1").unwrap(); - let f = m.secret_fn(&new, &p.id, SecretKind::IrcNickServPassword, SecretMode::SessionOnly, counting_prompt(asked.clone())); - assert_eq!(f().unwrap(), "hunter2"); - assert_eq!(asked.load(Ordering::SeqCst), 2); - m.close_all(); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn changed_host_keys_can_be_rejected_accepted_once_or_updated() { - use crate::clients::ssh::HostKeyInfo; - let (m, dir) = manager("hostkey"); - let answers: Arc>> = Arc::default(); - let prompts: Arc>>> = Arc::default(); - let (a, pr) = (answers.clone(), prompts.clone()); - m.set_sink(Arc::new(move |e| { - if let Event::HostKeyVerify { stored, reply, .. } = e { - pr.lock().push(stored); - let _ = reply.send(a.lock().remove(0)); - } - })); - let info = |fp: &str| HostKeyInfo { host: "h".into(), port: 22, key_type: "ssh-ed25519".into(), fingerprint: fp.into(), public_key_b64: "AAAA".into() }; - let stored = |m: &Manager| m.store.get_known_host("h", 22).unwrap().map(|k| k.fingerprint); - // Unknown key: accept once records nothing; accept & save records it. - let check = m.host_key_check("s", HostKeyPolicy::Ask); - answers.lock().push(HostKeyDecision::AcceptOnce); - check(&info("A")).unwrap(); - assert_eq!(stored(&m), None); - answers.lock().push(HostKeyDecision::AcceptAndSave); - check(&info("A")).unwrap(); - assert_eq!(stored(&m).as_deref(), Some("A")); - assert_eq!(prompts.lock().as_slice(), [None, None]); - // Same key again: no prompt. - check(&info("A")).unwrap(); - assert_eq!(prompts.lock().len(), 2); - // Changed key: reject, accept once (record untouched), accept & update (record replaced). - answers.lock().push(HostKeyDecision::Reject); - assert!(check(&info("B")).unwrap_err().contains("has changed")); - answers.lock().push(HostKeyDecision::AcceptOnce); - check(&info("B")).unwrap(); - assert_eq!(stored(&m).as_deref(), Some("A")); - answers.lock().push(HostKeyDecision::AcceptAndSave); - check(&info("B")).unwrap(); - assert_eq!(stored(&m).as_deref(), Some("B")); - assert_eq!(prompts.lock()[2..], [Some("A".to_string()), Some("A".into()), Some("A".into())]); - assert_eq!(m.store.list_known_hosts().unwrap().len(), 1); - // Strict and accept-new never prompt on a changed key. - for policy in [HostKeyPolicy::Strict, HostKeyPolicy::AcceptNew] { - assert!(m.host_key_check("s", policy)(&info("C")).unwrap_err().contains("has changed")); - } - assert_eq!(prompts.lock().len(), 5); - let _ = std::fs::remove_dir_all(dir); - } -} diff --git a/rust/core/src/sessions/credentials.rs b/rust/core/src/sessions/credentials.rs new file mode 100644 index 0000000..7c1b5ef --- /dev/null +++ b/rust/core/src/sessions/credentials.rs @@ -0,0 +1,189 @@ +//! Credential resolution (saved, session-only, ask-every-time), prompts and host-key checks. + +use super::*; + +pub(super) type SessionSecrets = Arc>; + +#[derive(Default)] +pub(super) struct SecretState { + /// `SecretMode::SessionOnly` values, asked once per session. Wiped from memory on drop. + pub(super) session_only: HashMap>, + /// `SecretMode::Saved` values typed during this attempt; stored only once it connects, so a + /// typo never ends up saved. + pub(super) pending: HashMap>, + /// Saved values this attempt used. + pub(super) used: HashSet, + /// Saved values the server rejected: the next attempt asks again instead of reusing them. + pub(super) rejected: HashSet, +} + +/// After a failed attempt, typed credentials are dropped and, when the server refused them, the +/// saved ones used are skipped next time so the user can enter a replacement. +pub(super) fn attempt_failed(state: &SessionSecrets, error: &str) { + let mut st = state.lock(); + st.pending.clear(); + if is_auth_error(error) { + let used = std::mem::take(&mut st.used); + st.rejected.extend(used); + } +} + +/// Whether a connect error means the server refused the credentials (as opposed to a network or +/// host-key failure). +pub fn is_auth_error(msg: &str) -> bool { + let m = msg.to_lowercase(); + // The last two are CredSSP's (RDP with NLA) wording for a rejected password. + ["unable to authenticate", "authentication failed", "permission denied", "no supported methods", "logondenied", "logon failure"] + .iter() + .any(|p| m.contains(p)) +} + +impl Manager { + // --- credentials -------------------------------------------------------------------- + + /// Resolves a credential per its SecretMode (Go's buildSecretCallback): saved values come from + /// the keychain then the vault (unless the server just rejected them), and newly typed ones + /// are saved once the connection succeeds; session-only values are asked once per session and + /// reused on reconnects (so e.g. a NickServ password entered once keeps identifying after a + /// drop). + pub fn secret_fn(&self, session: &str, profile_id: &str, kind: SecretKind, mode: SecretMode, prompt: Prompt) -> SecretFn { + let key = kind.key(profile_id); + let (secrets, store) = (self.secrets.clone(), self.store.clone()); + let (pid, kind) = (profile_id.to_string(), kind.as_str()); + let state: SessionSecrets = self.sessions.lock().get(session).map(|s| s.session_secrets.clone()).unwrap_or_default(); + Arc::new(move || { + if mode == SecretMode::Saved { + let mut st = state.lock(); + if let Some(v) = st.pending.get(&key) { + return Ok(v.to_string()); + } + if !st.rejected.remove(&key) + && let Some(v) = secrets.get(&key) + { + st.used.insert(key.clone()); + store.add_audit("secret.access", &pid, "", &format!("Accessed {kind} credential")); + return Ok(v); + } + } + if mode == SecretMode::SessionOnly + && let Some(v) = state.lock().session_only.get(&key).map(|v| v.to_string()) + { + return Ok(v); + } + let v = prompt(&format!("Enter {kind}:"))?; + let mut st = state.lock(); + match mode { + SecretMode::Saved => { + st.pending.insert(key.clone(), Zeroizing::new(v.clone())); + } + SecretMode::SessionOnly => { + st.session_only.insert(key.clone(), Zeroizing::new(v.clone())); + } + SecretMode::AskEveryTime => {} + } + Ok(v) + }) + } + + /// Blocks until the user answers a credential prompt (5 minutes, or cancel). + pub fn prompt(&self, session: &str, title: &str, message: &str, timeout: Duration) -> Result { + let (tx, rx) = mpsc::channel(); + self.emit(Event::SecretPrompt { session: session.into(), title: title.into(), message: message.into(), reply: tx }); + match rx.recv_timeout(timeout) { + Ok(v) => Ok(v), + Err(mpsc::RecvTimeoutError::Timeout) => err("secret prompt timed out".into()), + Err(_) => err("user cancelled secret prompt".into()), + } + } + + /// The standard interactive prompt for a session's credentials. + pub fn prompt_for(self: &Arc, session: &str, title: &str, timeout: Duration) -> Prompt { + let (me, sid, title) = (self.clone(), session.to_string(), title.to_string()); + Arc::new(move |msg| me.prompt(&sid, &title, msg, timeout)) + } + + /// Host-key policy check against the known-hosts table (Go's HostKeyVerifier). Under the + /// `ask` policy both unknown and changed keys go to the user, who can reject, accept once + /// (connect without touching the record) or accept and save/update the record. + pub fn host_key_check(self: &Arc, session: &str, policy: HostKeyPolicy) -> HostKeyCheck { + let (me, sid) = (self.clone(), session.to_string()); + Arc::new(move |k: &HostKeyInfo| { + let stored = me.store.get_known_host(&k.host, k.port as i64).map_err(|e| format!("failed to query known hosts: {e}"))?; + let save = || { + let n = now(); + me.store + .save_known_host(&KnownHost { + id: String::new(), + host: k.host.clone(), + port: k.port as i64, + key_type: k.key_type.clone(), + fingerprint: k.fingerprint.clone(), + public_key: k.public_key_b64.clone(), + first_seen: n, + last_seen: n, + trusted: true, + }) + .map_err(|e| e.to_string()) + }; + let touch = |mut s: KnownHost| { + s.last_seen = now(); + let _ = me.store.save_known_host(&s); + Ok(()) + }; + let changed = |s: &KnownHost| { + format!("host key for {} has changed! stored fingerprint: {}, received: {} — possible MITM attack", k.host, s.fingerprint, k.fingerprint) + }; + let ask = |stored: Option<&KnownHost>| -> std::result::Result { + let (tx, rx) = mpsc::channel(); + me.emit(Event::HostKeyVerify { + session: sid.clone(), + host: k.host.clone(), + key_type: k.key_type.clone(), + fingerprint: k.fingerprint.clone(), + stored: stored.map(|s| s.fingerprint.clone()), + reply: tx, + }); + match rx.recv_timeout(Duration::from_secs(60)) { + Ok(d) => Ok(d), + Err(mpsc::RecvTimeoutError::Disconnected) => Ok(HostKeyDecision::Reject), + Err(_) => Err("host key prompt failed: host key prompt timed out waiting for user response".into()), + } + }; + match (policy, stored) { + (HostKeyPolicy::Insecure, None) | (HostKeyPolicy::AcceptNew, None) => save(), + (HostKeyPolicy::Insecure, Some(s)) => touch(s), + (HostKeyPolicy::Strict, None) => Err(format!("host key for {}:{} is not known (policy: strict)", k.host, k.port)), + (HostKeyPolicy::Ask, None) => match ask(None)? { + HostKeyDecision::AcceptAndSave => save(), + HostKeyDecision::AcceptOnce => Ok(()), + HostKeyDecision::Reject => Err(format!("host key for {}:{} rejected by user", k.host, k.port)), + }, + (_, Some(s)) if s.fingerprint == k.fingerprint => touch(s), + (HostKeyPolicy::Ask, Some(s)) => match ask(Some(&s))? { + HostKeyDecision::AcceptAndSave => { + me.store.delete_known_host(&s.id).map_err(|e| e.to_string())?; + save()?; + me.store.add_audit( + "hostkey.update", + "", + &sid, + &format!("Replaced host key for {}:{} ({} -> {})", k.host, k.port, s.fingerprint, k.fingerprint), + ); + Ok(()) + } + HostKeyDecision::AcceptOnce => { + me.store.add_audit( + "hostkey.accept-once", + "", + &sid, + &format!("Accepted changed host key for {}:{} once ({})", k.host, k.port, k.fingerprint), + ); + Ok(()) + } + HostKeyDecision::Reject => Err(format!("{} (rejected by user)", changed(&s))), + }, + (_, Some(s)) => Err(changed(&s)), + } + }) + } +} diff --git a/rust/core/src/sessions/extras.rs b/rust/core/src/sessions/extras.rs new file mode 100644 index 0000000..b5f807f --- /dev/null +++ b/rust/core/src/sessions/extras.rs @@ -0,0 +1,114 @@ +//! Accessors and actions specific to one protocol (RAW, IRC, GitHub, desktops, SSH). + +use super::*; + +impl Manager { + // --- RAW ---------------------------------------------------------------------------- + + pub(super) fn raw(&self, id: &str) -> Result> { + match self.client(id)? { + Client::Raw(c) => Ok(c), + _ => err(format!("sessions: session {id:?} is not a RAW connection")), + } + } + + pub fn raw_write_text(&self, id: &str, s: &str) -> Result<()> { + self.raw(id)?.write_text(s) + } + + pub fn raw_write_hex(&self, id: &str, s: &str) -> Result<()> { + self.raw(id)?.write_hex(s) + } + + pub fn raw_write_escaped(&self, id: &str, s: &str) -> Result<()> { + self.raw(id)?.write_escaped(s) + } + + pub fn raw_history(&self, id: &str) -> Result> { + Ok(self.raw(id)?.history()) + } + + pub fn raw_clear_history(&self, id: &str) -> Result<()> { + self.raw(id)?.clear_history(); + Ok(()) + } + + pub fn raw_save_capture(&self, id: &str, path: &std::path::Path) -> Result<()> { + self.raw(id)?.save_capture(path) + } + + // --- IRC ---------------------------------------------------------------------------- + + pub fn irc(&self, id: &str) -> Result> { + match self.client(id)? { + Client::Irc(c, _) => Ok(c), + _ => err(format!("sessions: session {id:?} is not an IRC connection")), + } + } + + // --- GitHub ------------------------------------------------------------------------- + + pub fn github(&self, id: &str) -> Result> { + match self.client(id)? { + Client::Github(c, _) => Ok(c), + _ => err(format!("sessions: session {id:?} is not a GitHub connection")), + } + } + + pub fn rdp(&self, id: &str) -> Result> { + match self.client(id)? { + Client::Rdp(c, _) => Ok(c), + _ => err(format!("sessions: session {id:?} is not an RDP connection")), + } + } + + pub fn vnc(&self, id: &str) -> Result> { + match self.client(id)? { + Client::Vnc(c, _) => Ok(c), + _ => err(format!("sessions: session {id:?} is not a VNC connection")), + } + } + + // --- SSH extras --------------------------------------------------------------------- + + pub fn ssh(&self, id: &str) -> Result> { + match self.client(id)? { + Client::Ssh(c) => Ok(c), + _ => err(format!("sessions: session {id:?} is not an SSH connection")), + } + } + + pub fn ping(&self, id: &str) -> Result { + self.ssh(id)?.ping() + } + + pub fn cwd(&self, id: &str) -> Result { + self.ssh(id)?.run_command("pwd") + } + + pub fn start_forward(&self, id: &str, rule: PortForward) -> Result<()> { + self.ssh(id)?.start_forward(rule.clone())?; + self.emit(Event::ForwardStarted { session: id.into(), forward: rule }); + Ok(()) + } + + pub fn stop_forward(&self, id: &str, fid: &str) -> Result<()> { + self.ssh(id)?.stop_forward(fid)?; + self.emit(Event::ForwardStopped { session: id.into(), forward_id: fid.into() }); + Ok(()) + } + + pub fn forwards(&self, id: &str) -> Result> { + Ok(self.ssh(id)?.active_forwards()) + } + + pub fn sftp_download(&self, id: &str, remote: &str, local: &std::path::Path) -> Result<()> { + let s = self.ssh(id)?.sftp()?; + clients::sftp::download(&s, remote, local, &|p| self.emit(Event::SftpProgress { session: id.into(), progress: p })) + } + + pub fn sftp_upload(&self, id: &str, local: &std::path::Path, remote: &str) -> Result<()> { + let s = self.ssh(id)?.sftp()?; + clients::sftp::upload(&s, local, remote, &|p| self.emit(Event::SftpProgress { session: id.into(), progress: p })) + } +} diff --git a/rust/core/src/sessions/local.rs b/rust/core/src/sessions/local.rs new file mode 100644 index 0000000..07a7ddb --- /dev/null +++ b/rust/core/src/sessions/local.rs @@ -0,0 +1,152 @@ +//! Local shells and login scripts. + +use super::*; + +impl Manager { + // --- local shell -------------------------------------------------------------------- + + pub fn connect_local(self: &Arc, shell: &str) -> String { + let id = new_id(); + let info = SessionInfo { + id: id.clone(), + profile_id: String::new(), + profile_name: "Local Machine".into(), + protocol: Protocol::Local, + host: "localhost".into(), + port: 0, + state: SessionState::Connecting, + tab_name: "Local".into(), + pinned: false, + connected_at: now(), + error: String::new(), + }; + let epoch = self.new_session(info, None, shell.into(), SessionSecrets::default()); + self.emit_state(&id, SessionState::Connecting, ""); + let me = self.clone(); + let id2 = id.clone(); + std::thread::spawn(move || me.start_local(&id2, epoch, 80, 24)); + id + } + + /// Starts a fresh shell in an exited local tab, keeping its scrollback and last directory. + pub fn restart_local(self: &Arc, id: &str, cols: u16, rows: u16) -> Result<()> { + let epoch = self.with(id, |s| { + if s.info.protocol != Protocol::Local { + return Err(format!("sessions: session {id:?} is not local")); + } + if matches!(s.client, Client::Local(_)) || matches!(s.info.state, SessionState::Connecting | SessionState::Connected) { + return Err(format!("sessions: local session {id:?} is still running")); + } + s.info.state = SessionState::Connecting; + s.info.error.clear(); + Ok(s.epoch) + })??; + self.emit_state(id, SessionState::Connecting, ""); + let (me, id2) = (self.clone(), id.to_string()); + std::thread::spawn(move || me.start_local(&id2, epoch, cols, rows)); + Ok(()) + } + + pub(super) fn start_local(self: &Arc, id: &str, epoch: u64, cols: u16, rows: u16) { + let Ok((shell, cwd, pending)) = self.with(id, |s| (s.local_shell.clone(), s.local_cwd.clone(), s.pending_resize.take())) else { return }; + let (cols, rows) = pending.unwrap_or((cols, rows)); + let mut cb = self.terminal_callbacks(id, epoch); + // A local shell exiting keeps its tab ("press enter to restart"). + let (me, sid) = (self.clone(), id.to_string()); + cb.on_close = Arc::new(move || { + let (me, sid) = (me.clone(), sid.clone()); + std::thread::spawn(move || me.local_closed(&sid, epoch)); + }); + let c = LocalShell::new(&shell, cwd, cols, rows, cb); + let r = c.start().map(|_| Client::Local(c.clone())); + self.finish_connect(id, epoch, r, |me| me.store.add_audit("connect", "", id, "Connected via local to localhost")); + } + + pub(super) fn local_closed(self: &Arc, id: &str, epoch: u64) { + let cwd = self.with(id, |s| { + if s.epoch != epoch || s.closed { + return None; + } + match std::mem::replace(&mut s.client, Client::None) { + Client::Local(c) => Some(c.working_directory()), + other => { + s.client = other; + None + } + } + }); + if let Ok(Some(cwd)) = cwd { + let _ = self.with(id, |s| s.local_cwd = Some(cwd)); + self.handle_closed(id, epoch); + } + } +} + +impl Manager { + // --- login scripts ------------------------------------------------------------------ + + pub(super) fn start_login_scripts(self: &Arc, id: &str, epoch: u64, steps: Vec) { + if steps.is_empty() { + return; + } + let Ok(login) = self.with(id, |s| s.login.clone()) else { return }; + *login.lock() = LoginBuf { active: true, buf: vec![] }; + let (me, id) = (self.clone(), id.to_string()); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(500)); + for (i, step) in steps.iter().enumerate() { + // Wait until the connect has been recorded as Connected. + let mut waited = 0; + while me.with(&id, |s| s.info.state).ok() == Some(SessionState::Connecting) && waited < 100 { + std::thread::sleep(Duration::from_millis(50)); + waited += 1; + } + if !me.is_current(&id, epoch) || me.with(&id, |s| s.info.state).ok() != Some(SessionState::Connected) { + break; + } + match step.kind.as_str() { + "wait" => { + let timeout = Duration::from_secs(if step.timeout <= 0 { 10 } else { step.timeout as u64 }); + if !wait_for_output(&login, &step.value, timeout) { + me.emit(Event::Debug { + session: id.clone(), + message: format!("login script step {} timed out waiting for {:?}", i + 1, step.value), + }); + break; + } + } + "send" => { + if let Err(e) = me.write(&id, format!("{}\n", step.value).as_bytes()) { + me.emit(Event::Debug { session: id.clone(), message: format!("login script step {} failed to send: {e}", i + 1) }); + break; + } + std::thread::sleep(Duration::from_millis(200)); + } + _ => {} + } + } + *login.lock() = LoginBuf::default(); + }); + } +} + +pub(super) fn wait_for_output(login: &Mutex, pattern: &str, timeout: Duration) -> bool { + if pattern.is_empty() { + return true; + } + let deadline = std::time::Instant::now() + timeout; + while std::time::Instant::now() < deadline { + { + let mut l = login.lock(); + let text = String::from_utf8_lossy(&l.buf).into_owned(); + if let Some(i) = text.find(pattern) { + let consumed = text[..i + pattern.len()].len(); + let n = consumed.min(l.buf.len()); + l.buf.drain(..n); + return true; + } + } + std::thread::sleep(Duration::from_millis(100)); + } + false +} diff --git a/rust/core/src/sessions/mod.rs b/rust/core/src/sessions/mod.rs new file mode 100644 index 0000000..d02c76e --- /dev/null +++ b/rust/core/src/sessions/mod.rs @@ -0,0 +1,895 @@ +//! The session manager: one entry per open tab, each owning a protocol client. A port of +//! internal/sessions — same lifecycle, reconnect backoff, login scripts, recording hooks and +//! credential resolution — reporting to the UI through `Event`s. + +use crate::Result; +use crate::clients::local::LocalShell; +use crate::clients::ssh::{HostKeyCheck, HostKeyInfo, SecretFn, SshClient}; +use crate::clients::{self, Callbacks, github, irc, raw, serial, telnet}; +use crate::models::*; +use crate::recording::{self, Recorder}; +use crate::secrets::{SecretKind, Secrets}; +use crate::storage::Store; +use parking_lot::Mutex; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc; +use std::time::Duration; +use zeroize::Zeroizing; + +mod credentials; +mod extras; +mod local; +mod protocols; + +pub use credentials::is_auth_error; +use credentials::{SessionSecrets, attempt_failed}; + +/// Everything the UI hears about. Prompts carry their reply channel; dropping it cancels. +pub enum Event { + Data { + session: String, + data: Vec, + }, + State { + session: String, + state: SessionState, + error: String, + retry: Option, + }, + Closed { + session: String, + }, + RawData { + session: String, + data: Vec, + direction: String, + timestamp: i64, + }, + Debug { + session: String, + message: String, + }, + GithubStatus { + session: String, + status: String, + }, + GithubRepos { + session: String, + login: String, + repos: Vec, + }, + GithubError { + session: String, + error: String, + }, + /// Something the user should know about that didn't stop the session (e.g. a credential that + /// couldn't be saved). + Warning { + session: String, + message: String, + }, + Irc { + session: String, + event: irc::Event, + }, + ForwardStarted { + session: String, + forward: PortForward, + }, + ForwardStopped { + session: String, + forward_id: String, + }, + ForwardError { + session: String, + forward_id: String, + label: String, + error: String, + }, + RecordingStarted { + session: String, + }, + RecordingStopped { + session: String, + duration: f64, + events: usize, + }, + SftpProgress { + session: String, + progress: clients::sftp::Progress, + }, + SecretPrompt { + session: String, + title: String, + message: String, + reply: mpsc::Sender, + }, + /// A host key needs the user's decision: unknown (`stored` is None) or changed (`stored` is + /// the fingerprint on record). + HostKeyVerify { + session: String, + host: String, + key_type: String, + fingerprint: String, + stored: Option, + reply: mpsc::Sender, + }, + DesktopReady { + session: String, + }, + DesktopFrame { + session: String, + }, + /// The remote desktop's clipboard changed. + VncClipboard { + session: String, + text: String, + }, +} + +/// The user's answer to a host-key prompt. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HostKeyDecision { + Reject, + /// Connect, leaving the known-hosts record as it is. + AcceptOnce, + /// Connect and record this key (replacing a changed one). + AcceptAndSave, +} + +#[derive(Clone, Copy, Debug)] +pub struct Retry { + pub attempt: i64, + pub max: i64, + pub delay_secs: u64, +} + +pub type EventSink = Arc; +/// Asks the user for a credential (blocks the calling worker thread). +pub type Prompt = Arc Result + Send + Sync>; + +#[derive(Clone)] +enum Client { + None, + Ssh(Arc), + Telnet(Arc), + Raw(Arc), + Serial(Arc), + Local(Arc), + Github(Arc, Arc), + Irc(Arc, Option>), + Vnc(Arc, Option>), + Rdp(Arc, Option>), +} + +impl Client { + fn close(&self) { + match self { + Client::None => {} + Client::Ssh(c) => c.close(), + Client::Telnet(c) => c.close(), + Client::Raw(c) => c.close(), + Client::Serial(c) => c.close(), + Client::Local(c) => c.close(), + Client::Github(_, stop) => stop.store(true, Ordering::SeqCst), + Client::Irc(c, tunnel) => { + c.close(); + if let Some(t) = tunnel { + t.close(); + } + } + Client::Vnc(c, tunnel) => { + c.close(); + if let Some(t) = tunnel { + t.close(); + } + } + Client::Rdp(c, tunnel) => { + c.close(); + if let Some(t) = tunnel { + t.close(); + } + } + } + } +} + +#[derive(Default)] +struct LoginBuf { + active: bool, + buf: Vec, +} + +struct Session { + info: SessionInfo, + /// Distinguishes this Session object from a later one with the same id (Go compared pointers). + epoch: u64, + closed: bool, + client: Client, + profile: Option, + local_shell: String, + local_cwd: Option, + recorder: Option>, + reconnect_count: i64, + cancel_reconnect: Option>, + pending_resize: Option<(u16, u16)>, + login: Arc>, + /// Credential state kept across reconnects of this session. + session_secrets: SessionSecrets, +} + +pub struct Manager { + sessions: Mutex>, + store: Arc, + secrets: Arc, + recordings: recording::Store, + sink: Mutex>, + next_epoch: std::sync::atomic::AtomicU64, +} + +fn err(msg: String) -> Result { + Err(msg.into()) +} + +impl Manager { + pub fn new(store: Arc, secrets: Arc, recordings_dir: PathBuf) -> Arc { + Arc::new(Manager { + sessions: Mutex::new(HashMap::new()), + store, + secrets, + recordings: recording::Store::new(recordings_dir), + sink: Mutex::new(None), + next_epoch: 1.into(), + }) + } + + pub fn set_sink(&self, sink: EventSink) { + *self.sink.lock() = Some(sink); + } + + fn emit(&self, e: Event) { + let sink = self.sink.lock().clone(); + if let Some(s) = sink { + s(e); + } + } + + fn emit_state(&self, id: &str, state: SessionState, error: &str) { + self.emit(Event::State { session: id.into(), state, error: error.into(), retry: None }); + } + + /// True while `id` still names the Session object with `epoch` and it isn't closed. + fn is_current(&self, id: &str, epoch: u64) -> bool { + self.sessions.lock().get(id).is_some_and(|s| s.epoch == epoch && !s.closed) + } + + fn with(&self, id: &str, f: impl FnOnce(&mut Session) -> R) -> Result { + let mut map = self.sessions.lock(); + match map.get_mut(id) { + Some(s) => Ok(f(s)), + None => err(format!("sessions: session {id:?} not found")), + } + } + + fn client(&self, id: &str) -> Result { + self.with(id, |s| s.client.clone()) + } + + fn new_session(&self, info: SessionInfo, profile: Option, shell: String, session_secrets: SessionSecrets) -> u64 { + let epoch = self.next_epoch.fetch_add(1, Ordering::SeqCst); + let s = Session { + info, + epoch, + closed: false, + client: Client::None, + profile, + local_shell: shell, + local_cwd: None, + recorder: None, + reconnect_count: 0, + cancel_reconnect: None, + pending_resize: None, + login: Arc::default(), + session_secrets, + }; + let id = s.info.id.clone(); + self.sessions.lock().insert(id, s); + epoch + } + + /// Opens a session for `profile` and connects it on a worker thread. Returns the session id + /// immediately; progress arrives as `Event::State`. + pub fn connect(self: &Arc, profile: Profile) -> String { + self.connect_with(profile, SessionSecrets::default()) + } + + /// `connect` with session-only credentials carried over from a previous session (reconnect). + fn connect_with(self: &Arc, profile: Profile, session_secrets: SessionSecrets) -> String { + let id = new_id(); + let prompt = self.prompt_for(&id, "Credentials Required", Duration::from_secs(300)); + let info = SessionInfo { + id: id.clone(), + profile_id: profile.id.clone(), + profile_name: profile.display_name.clone(), + protocol: profile.protocol, + host: profile.host.clone(), + port: profile.port, + state: SessionState::Connecting, + tab_name: profile.tab_name(), + pinned: false, + connected_at: now(), + error: String::new(), + }; + let epoch = self.new_session(info, Some(profile.clone()), String::new(), session_secrets); + self.emit_state(&id, SessionState::Connecting, ""); + let me = self.clone(); + let id2 = id.clone(); + std::thread::spawn(move || { + let r = me.connect_protocol(&id2, epoch, &profile, prompt); + me.finish_connect(&id2, epoch, r, |me| { + me.store.add_audit("connect", &profile.id, &id2, &format!("Connected via {} to {}", profile.protocol.id(), profile.host)) + }); + }); + id + } + + fn finish_connect(&self, id: &str, epoch: u64, r: Result, on_ok: impl FnOnce(&Self)) { + let mut map = self.sessions.lock(); + let current = map.get_mut(id).filter(|s| s.epoch == epoch && !s.closed); + match (r, current) { + (Ok(client), None) => { + drop(map); + client.close(); + } + (Err(_), None) => {} + (Err(e), Some(s)) => { + s.info.state = SessionState::Error; + s.info.error = e.to_string(); + attempt_failed(&s.session_secrets, &e.to_string()); + drop(map); + self.emit_state(id, SessionState::Error, &e.to_string()); + } + (Ok(client), Some(s)) => { + let typed = std::mem::take(&mut s.session_secrets.lock().pending); + s.client = client.clone(); + s.info.state = SessionState::Connected; + s.info.connected_at = now(); + s.reconnect_count = 0; + let pending = s.pending_resize.take(); + drop(map); + if let Some((c, r)) = pending { + let _ = self.resize_client(&client, c, r); + } + for (key, value) in typed { + if let Err(e) = self.secrets.set_checked(&key, &value) { + self.emit(Event::Warning { session: id.into(), message: format!("Credential not saved: {e}") }); + } + } + self.emit_state(id, SessionState::Connected, ""); + on_ok(self); + } + } + } + + fn connect_protocol(self: &Arc, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result { + let _ = self.with(id, |s| { + let mut st = s.session_secrets.lock(); + st.pending.clear(); + st.used.clear(); + }); + match p.protocol { + Protocol::Ssh => self.connect_ssh(id, epoch, p, prompt), + Protocol::Telnet => { + let debug = p.telnet.as_ref().is_some_and(|t| t.negotiation_debug).then(|| { + let (me, id) = (self.clone(), id.to_string()); + Arc::new(move |m: String| me.emit(Event::Debug { session: id.clone(), message: m })) as clients::DebugFn + }); + let c = Arc::new(telnet::TelnetClient::new(p, self.terminal_callbacks(id, epoch), debug)); + c.connect()?; + self.start_login_scripts(id, epoch, p.telnet.as_ref().map(|t| t.login_scripts.clone()).unwrap_or_default()); + Ok(Client::Telnet(c)) + } + Protocol::Raw => { + let cb = self.terminal_callbacks(id, epoch); + let (me, sid) = (self.clone(), id.to_string()); + let on_data: raw::RawDataFn = + Arc::new(move |d, dir, ts| me.emit(Event::RawData { session: sid.clone(), data: d.to_vec(), direction: dir.into(), timestamp: ts })); + let c = Arc::new(raw::RawClient::new(p, on_data, cb.on_error, cb.on_close)); + c.connect()?; + Ok(Client::Raw(c)) + } + Protocol::Serial => { + let c = Arc::new(serial::SerialClient::new(p, self.terminal_callbacks(id, epoch))); + c.connect()?; + Ok(Client::Serial(c)) + } + Protocol::Github => self.connect_github(id, epoch, p, prompt), + Protocol::Irc => self.connect_irc(id, epoch, p, prompt), + Protocol::Vnc => self.connect_vnc(id, epoch, p, prompt), + Protocol::Rdp => self.connect_rdp(id, epoch, p, prompt), + Protocol::Local => err("unsupported protocol \"local\"".into()), + } + } + + /// Callbacks for terminal-style clients: output to the UI, recording and login scripts. + fn terminal_callbacks(self: &Arc, id: &str, epoch: u64) -> Callbacks { + let login = self.sessions.lock().get(id).map(|s| s.login.clone()).unwrap_or_default(); + let (me, sid) = (self.clone(), id.to_string()); + let on_data = Arc::new(move |d: &[u8]| { + me.emit(Event::Data { session: sid.clone(), data: d.to_vec() }); + let rec = me.sessions.lock().get(&sid).and_then(|s| s.recorder.clone()); + if let Some(r) = rec { + r.write_output(d); + } + let mut l = login.lock(); + if l.active { + l.buf.extend_from_slice(d); + if l.buf.len() > 16384 { + let cut = l.buf.len() - 16384; + l.buf.drain(..cut); + } + } + }); + let (me, sid) = (self.clone(), id.to_string()); + let on_error = Arc::new(move |e: String| { + let ok = me + .with(&sid, |s| { + if s.epoch != epoch { + return false; + } + s.info.state = SessionState::Error; + s.info.error = e.clone(); + true + }) + .unwrap_or(false); + if ok { + me.emit_state(&sid, SessionState::Error, &e); + } + }); + let (me, sid) = (self.clone(), id.to_string()); + let on_close = Arc::new(move || { + let me = me.clone(); + let sid = sid.clone(); + // Close callbacks can fire inside a client's own lock; handle them off-thread. + std::thread::spawn(move || me.handle_closed(&sid, epoch)); + }); + Callbacks { on_data, on_error, on_close } + } + + // --- lifecycle ---------------------------------------------------------------------- + + /// Explicit close: the tab is gone, so no reconnect. + pub fn disconnect(&self, id: &str) -> Result<()> { + let s = self.sessions.lock().remove(id).ok_or_else(|| format!("sessions: session {id:?} not found"))?; + self.close_session(s); + self.store.add_audit("disconnect", "", id, "Session disconnected"); + Ok(()) + } + + /// Closes and re-opens a session from its profile, keeping the tab name and pin; returns the + /// new session id. + pub fn reconnect(self: &Arc, id: &str) -> Result { + let s = self.sessions.lock().remove(id).ok_or_else(|| format!("sessions: session {id:?} not found"))?; + let (profile, tab, pinned, proto, shell) = (s.profile.clone(), s.info.tab_name.clone(), s.info.pinned, s.info.protocol, s.local_shell.clone()); + let session_secrets = s.session_secrets.clone(); + self.close_session(s); + let new = match (proto, profile) { + (Protocol::Local, _) => self.connect_local(&shell), + (_, Some(p)) => self.connect_with(p, session_secrets), + _ => return err("sessions: nothing to reconnect".into()), + }; + let _ = self.with(&new, |s| { + s.info.tab_name = tab; + s.info.pinned = pinned; + }); + Ok(new) + } + + fn close_session(&self, mut s: Session) { + s.closed = true; + if let Some(c) = s.cancel_reconnect.take() { + c.store(true, Ordering::SeqCst); + } + if let Some(r) = s.recorder.take() { + let _ = r.close(); + } + s.client.close(); + } + + fn handle_closed(self: &Arc, id: &str, epoch: u64) { + let profile = { + let mut map = self.sessions.lock(); + let Some(s) = map.get_mut(id).filter(|s| s.epoch == epoch && !s.closed) else { return }; + s.info.state = SessionState::Disconnected; + if !matches!(s.client, Client::Local(_)) { + s.client = Client::None; + } + s.profile.clone() + }; + self.store.add_audit("disconnect", "", id, "Session disconnected"); + if let Some(p) = profile.filter(|p| p.auto_reconnect()) { + self.schedule_reconnect(id, epoch, p); + return; + } + self.emit(Event::Closed { session: id.into() }); + self.emit_state(id, SessionState::Disconnected, ""); + } + + /// Exponential backoff 1, 2, 4 … 30 s, up to the profile's attempt limit. + fn schedule_reconnect(self: &Arc, id: &str, epoch: u64, p: Profile) { + let max = p.max_reconnect_tries(); + let cancel = Arc::new(AtomicBool::new(false)); + let attempt = self.with(id, |s| { + s.reconnect_count += 1; + if s.reconnect_count > max { + s.info.state = SessionState::Disconnected; + return None; + } + s.info.state = SessionState::Reconnecting; + s.info.error.clear(); + if let Some(c) = s.cancel_reconnect.replace(cancel.clone()) { + c.store(true, Ordering::SeqCst); + } + Some(s.reconnect_count) + }); + let Ok(attempt) = attempt else { return }; + let Some(attempt) = attempt else { + self.emit(Event::Closed { session: id.into() }); + self.emit_state(id, SessionState::Disconnected, "auto-reconnect failed after maximum attempts"); + return; + }; + let delay = (1u64 << (attempt - 1).min(5)).min(30); + self.emit(Event::State { + session: id.into(), + state: SessionState::Reconnecting, + error: String::new(), + retry: Some(Retry { attempt, max, delay_secs: delay }), + }); + let (me, id) = (self.clone(), id.to_string()); + std::thread::spawn(move || { + let deadline = std::time::Instant::now() + Duration::from_secs(delay); + while std::time::Instant::now() < deadline { + if cancel.load(Ordering::SeqCst) { + return; + } + std::thread::sleep(Duration::from_millis(100)); + } + let ok = me + .with(&id, |s| { + if s.epoch != epoch || s.closed || cancel.load(Ordering::SeqCst) { + return false; + } + s.client = Client::None; + s.info.state = SessionState::Connecting; + true + }) + .unwrap_or(false); + if !ok { + return; + } + me.emit_state(&id, SessionState::Connecting, ""); + let prompt = me.prompt_for(&id, "Reconnection Credentials", Duration::from_secs(300)); + match me.connect_protocol(&id, epoch, &p, prompt) { + Ok(c) => me.finish_connect(&id, epoch, Ok(c), |_| {}), + Err(e) => { + let current = me.with(&id, |s| { + if s.epoch != epoch || s.closed { + return false; + } + s.info.state = SessionState::Error; + s.info.error = e.to_string(); + attempt_failed(&s.session_secrets, &e.to_string()); + true + }); + if current.unwrap_or(false) { + me.emit_state(&id, SessionState::Error, &e.to_string()); + me.schedule_reconnect(&id, epoch, p); + } + } + } + }); + } + + pub fn cancel_reconnect(&self, id: &str) { + let found = self + .with(id, |s| { + if let Some(c) = s.cancel_reconnect.take() { + c.store(true, Ordering::SeqCst); + s.reconnect_count = 0; + s.info.state = SessionState::Disconnected; + } + }) + .is_ok(); + if found { + self.emit_state(id, SessionState::Disconnected, ""); + } + } + + pub fn close_all(&self) { + let all: Vec = self.sessions.lock().drain().map(|(_, s)| s).collect(); + for s in all { + self.close_session(s); + } + } + + // --- I/O ---------------------------------------------------------------------------- + + pub fn write(&self, id: &str, data: &[u8]) -> Result<()> { + let (client, rec) = self.with(id, |s| (s.client.clone(), s.recorder.clone()))?; + if let Some(r) = rec { + r.write_input(data); + } + match client { + Client::Ssh(c) => c.write(data), + Client::Telnet(c) => c.write(data), + Client::Raw(c) => c.write(data), + Client::Serial(c) => c.write(data), + Client::Local(c) => c.write(data), + _ => err(format!("sessions: no active client for session {id:?}")), + } + } + + fn resize_client(&self, c: &Client, cols: u16, rows: u16) -> Result<()> { + if cols == 0 || rows == 0 { + return Ok(()); + } + match c { + Client::Ssh(c) => c.resize(cols as u32, rows as u32), + Client::Telnet(c) => c.resize(cols, rows), + Client::Local(c) => c.resize(cols, rows), + // For a remote desktop the "size" is the tab's size in pixels. + Client::Rdp(c, _) => { + c.resize(cols, rows); + Ok(()) + } + _ => Ok(()), + } + } + + /// Resizes the remote PTY; before a client exists the size is kept and applied on connect. + pub fn resize(&self, id: &str, cols: u16, rows: u16) -> Result<()> { + let c = self.with(id, |s| { + if matches!(s.client, Client::None) { + s.pending_resize = Some((cols, rows)); + } + s.client.clone() + })?; + self.resize_client(&c, cols, rows) + } + + pub fn info(&self, id: &str) -> Option { + self.sessions.lock().get(id).map(|s| s.info.clone()) + } + + pub fn list(&self) -> Vec { + self.sessions.lock().values().map(|s| s.info.clone()).collect() + } + + pub fn profile(&self, id: &str) -> Option { + self.sessions.lock().get(id).and_then(|s| s.profile.clone()) + } + + pub fn rename(&self, id: &str, name: &str) -> Result<()> { + self.with(id, |s| s.info.tab_name = name.into()) + } + + pub fn pin(&self, id: &str, pinned: bool) -> Result<()> { + self.with(id, |s| s.info.pinned = pinned) + } + + pub fn send_break(&self, id: &str) -> Result<()> { + match self.client(id)? { + Client::Telnet(c) => c.send_break(), + Client::Serial(c) => c.send_break(), + _ => err(format!("sessions: session {id:?} does not support break")), + } + } + + pub fn set_serial_lines(&self, id: &str, dtr: bool, rts: bool) -> Result<()> { + match self.client(id)? { + Client::Serial(c) => c.set_lines(dtr, rts), + _ => err(format!("sessions: session {id:?} is not a serial connection")), + } + } + + // --- recording ---------------------------------------------------------------------- + + pub fn start_recording(&self, id: &str, cols: u16, rows: u16) -> Result<()> { + let (exists, title) = self.with(id, |s| (s.recorder.is_some(), format!("{} — {}", s.info.profile_name, s.info.host)))?; + if exists { + return err(format!("sessions: session {id:?} is already being recorded")); + } + let r = Arc::new( + Recorder::new(self.recordings.dir(), id, &title, cols as i64, rows as i64).map_err(|e| format!("sessions: failed to start recording: {e}"))?, + ); + self.with(id, |s| s.recorder = Some(r))?; + self.emit(Event::RecordingStarted { session: id.into() }); + Ok(()) + } + + pub fn stop_recording(&self, id: &str) -> Result<()> { + let r = self.with(id, |s| s.recorder.take())?.ok_or_else(|| format!("sessions: session {id:?} is not being recorded"))?; + r.close().map_err(|e| format!("sessions: failed to stop recording: {e}"))?; + self.emit(Event::RecordingStopped { session: id.into(), duration: r.duration(), events: r.event_count() }); + Ok(()) + } + + pub fn is_recording(&self, id: &str) -> bool { + self.with(id, |s| s.recorder.is_some()).unwrap_or(false) + } + + pub fn recordings(&self) -> &recording::Store { + &self.recordings + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::secrets::{Keychain, Secrets, Vault}; + use std::sync::atomic::AtomicUsize; + + fn manager(name: &str) -> (Arc, PathBuf) { + let dir = std::env::temp_dir().join(format!("nexus-secretfn-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let store = Arc::new(Store::open(&dir.join("nexus.db")).unwrap()); + let secrets = Arc::new(Secrets { vault: Vault::new(dir.join("vault")), keychain: Keychain::new("NexusTerminalTest"), keychain_ok: false }); + (Manager::new(store, secrets, dir.join("recordings")), dir) + } + + fn counting_prompt(n: Arc) -> Prompt { + Arc::new(move |_| { + n.fetch_add(1, Ordering::SeqCst); + Ok("hunter2".into()) + }) + } + + #[test] + fn saved_credentials_are_stored_only_after_success_and_reasked_after_rejection() { + let (m, dir) = manager("saved"); + let warnings: Arc>> = Arc::default(); + let w = warnings.clone(); + m.set_sink(Arc::new(move |e| { + if let Event::Warning { message, .. } = e { + w.lock().push(message); + } + })); + let p = Profile { host: "127.0.0.1".into(), port: 1, ..Profile::new(Protocol::Raw) }; + let key = SecretKind::Password.key(&p.id); + let info = |id: &str| SessionInfo { + id: id.into(), + profile_id: p.id.clone(), + profile_name: String::new(), + protocol: Protocol::Raw, + host: p.host.clone(), + port: p.port, + state: SessionState::Connecting, + tab_name: String::new(), + pinned: false, + connected_at: now(), + error: String::new(), + }; + let asked = Arc::new(AtomicUsize::new(0)); + let resolve = |m: &Arc, id: &str| m.secret_fn(id, &p.id, SecretKind::Password, SecretMode::Saved, counting_prompt(asked.clone()))().unwrap(); + + // Locked store: the credential can't be saved, and the user is told. + let e = m.new_session(info("s0"), Some(p.clone()), String::new(), SessionSecrets::default()); + resolve(&m, "s0"); + m.finish_connect("s0", e, Ok(Client::None), |_| {}); + assert_eq!(warnings.lock().len(), 1); + + m.secrets.vault.initialise("pw").unwrap(); + let e = m.new_session(info("s1"), Some(p.clone()), String::new(), SessionSecrets::default()); + // A typed password the server rejects is never stored. + resolve(&m, "s1"); + m.finish_connect("s1", e, Err("ssh: unable to authenticate".into()), |_| {}); + assert!(m.secrets.get(&key).is_none()); + // Typed again and accepted: stored. + resolve(&m, "s1"); + m.finish_connect("s1", e, Ok(Client::None), |_| {}); + assert_eq!(m.secrets.get(&key).as_deref(), Some("hunter2")); + assert_eq!(asked.load(Ordering::SeqCst), 3); + + // The saved value is reused; once the server rejects it, the next attempt asks again. + let e = m.new_session(info("s2"), Some(p.clone()), String::new(), SessionSecrets::default()); + resolve(&m, "s2"); + assert_eq!(asked.load(Ordering::SeqCst), 3); + m.finish_connect("s2", e, Err("VNC authentication failed".into()), |_| {}); + resolve(&m, "s2"); + assert_eq!(asked.load(Ordering::SeqCst), 4); + // A network error doesn't count as a rejection. + m.finish_connect("s2", e, Err("connection refused".into()), |_| {}); + resolve(&m, "s2"); + assert_eq!(asked.load(Ordering::SeqCst), 4); + m.close_all(); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn session_only_secrets_survive_reconnects() { + let (m, dir) = manager("cache"); + let p = Profile { host: "127.0.0.1".into(), port: 1, ..Profile::new(Protocol::Raw) }; + let info = SessionInfo { + id: "s1".into(), + profile_id: p.id.clone(), + profile_name: String::new(), + protocol: Protocol::Raw, + host: p.host.clone(), + port: p.port, + state: SessionState::Connecting, + tab_name: String::new(), + pinned: false, + connected_at: now(), + error: String::new(), + }; + m.new_session(info, Some(p.clone()), String::new(), SessionSecrets::default()); + let asked = Arc::new(AtomicUsize::new(0)); + // Two separate resolutions (as two connects of the same session make) prompt once. + for _ in 0..2 { + let f = m.secret_fn("s1", &p.id, SecretKind::IrcNickServPassword, SecretMode::SessionOnly, counting_prompt(asked.clone())); + assert_eq!(f().unwrap(), "hunter2"); + } + assert_eq!(asked.load(Ordering::SeqCst), 1); + // Ask-every-time still prompts each time. + let f = m.secret_fn("s1", &p.id, SecretKind::IrcNickServPassword, SecretMode::AskEveryTime, counting_prompt(asked.clone())); + f().unwrap(); + assert_eq!(asked.load(Ordering::SeqCst), 2); + // An explicit reconnect carries the cache to the new session id. + let new = m.reconnect("s1").unwrap(); + let f = m.secret_fn(&new, &p.id, SecretKind::IrcNickServPassword, SecretMode::SessionOnly, counting_prompt(asked.clone())); + assert_eq!(f().unwrap(), "hunter2"); + assert_eq!(asked.load(Ordering::SeqCst), 2); + m.close_all(); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn changed_host_keys_can_be_rejected_accepted_once_or_updated() { + use crate::clients::ssh::HostKeyInfo; + let (m, dir) = manager("hostkey"); + let answers: Arc>> = Arc::default(); + let prompts: Arc>>> = Arc::default(); + let (a, pr) = (answers.clone(), prompts.clone()); + m.set_sink(Arc::new(move |e| { + if let Event::HostKeyVerify { stored, reply, .. } = e { + pr.lock().push(stored); + let _ = reply.send(a.lock().remove(0)); + } + })); + let info = |fp: &str| HostKeyInfo { host: "h".into(), port: 22, key_type: "ssh-ed25519".into(), fingerprint: fp.into(), public_key_b64: "AAAA".into() }; + let stored = |m: &Manager| m.store.get_known_host("h", 22).unwrap().map(|k| k.fingerprint); + // Unknown key: accept once records nothing; accept & save records it. + let check = m.host_key_check("s", HostKeyPolicy::Ask); + answers.lock().push(HostKeyDecision::AcceptOnce); + check(&info("A")).unwrap(); + assert_eq!(stored(&m), None); + answers.lock().push(HostKeyDecision::AcceptAndSave); + check(&info("A")).unwrap(); + assert_eq!(stored(&m).as_deref(), Some("A")); + assert_eq!(prompts.lock().as_slice(), [None, None]); + // Same key again: no prompt. + check(&info("A")).unwrap(); + assert_eq!(prompts.lock().len(), 2); + // Changed key: reject, accept once (record untouched), accept & update (record replaced). + answers.lock().push(HostKeyDecision::Reject); + assert!(check(&info("B")).unwrap_err().contains("has changed")); + answers.lock().push(HostKeyDecision::AcceptOnce); + check(&info("B")).unwrap(); + assert_eq!(stored(&m).as_deref(), Some("A")); + answers.lock().push(HostKeyDecision::AcceptAndSave); + check(&info("B")).unwrap(); + assert_eq!(stored(&m).as_deref(), Some("B")); + assert_eq!(prompts.lock()[2..], [Some("A".to_string()), Some("A".into()), Some("A".into())]); + assert_eq!(m.store.list_known_hosts().unwrap().len(), 1); + // Strict and accept-new never prompt on a changed key. + for policy in [HostKeyPolicy::Strict, HostKeyPolicy::AcceptNew] { + assert!(m.host_key_check("s", policy)(&info("C")).unwrap_err().contains("has changed")); + } + assert_eq!(prompts.lock().len(), 5); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/rust/core/src/sessions/protocols.rs b/rust/core/src/sessions/protocols.rs new file mode 100644 index 0000000..fc173a1 --- /dev/null +++ b/rust/core/src/sessions/protocols.rs @@ -0,0 +1,413 @@ +//! Per-protocol connect: SSH, GitHub, IRC, VNC and RDP, and the SSH tunnels they can use. + +use super::*; + +impl Manager { + // --- per-protocol connect ----------------------------------------------------------- + + pub(super) fn connect_ssh(self: &Arc, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result { + let mut p = p.clone(); + let sc = p.ssh.get_or_insert_with(SshConfig::defaults).clone(); + let password = self.secret_fn(id, &p.id, SecretKind::Password, sc.secret_mode, prompt.clone()); + let passphrase = self.secret_fn(id, &p.id, SecretKind::Passphrase, sc.key_secret_mode, prompt); + let c = SshClient::new(&p, self.terminal_callbacks(id, epoch)); + let stored = |k: SecretKind| self.secrets.get(&k.key(&p.id)).unwrap_or_default(); + c.set_stored_secrets(stored(SecretKind::Totp), stored(SecretKind::ProxyPassword)); + c.connect(self.host_key_check(id, sc.host_key_policy), Some(password), Some(passphrase))?; + for pf in sc.port_forwards.iter().filter(|f| f.enabled) { + match c.start_forward(pf.clone()) { + Ok(()) => self.emit(Event::ForwardStarted { session: id.into(), forward: pf.clone() }), + Err(e) => self.emit(Event::ForwardError { session: id.into(), forward_id: pf.id.clone(), label: pf.label.clone(), error: e.to_string() }), + } + } + self.start_login_scripts(id, epoch, sc.login_scripts.clone()); + Ok(Client::Ssh(c)) + } + + /// An SSH connection used only to tunnel another protocol (IRC, VNC). + #[allow(clippy::too_many_arguments)] + pub(super) fn tunnel( + self: &Arc, + id: &str, + profile_id: &str, + host: &str, + port: i64, + user: &str, + method: &str, + key: &str, + agent: bool, + mode: SecretMode, + kind: &str, + prompt: Prompt, + ) -> Result> { + let mut tp = Profile::new(Protocol::Ssh); + tp.host = host.into(); + tp.port = if port == 0 { 22 } else { port }; + tp.username = user.into(); + let c = tp.ssh.as_mut().unwrap(); + c.auth_methods = vec![if method.is_empty() { "publickey".into() } else { method.into() }]; + c.private_key_path = key.into(); + c.agent_auth = agent; + let (pw_kind, pp_kind) = match kind { + "vnc" => (SecretKind::VncSshPassword, SecretKind::VncSshPassphrase), + "rdp" => (SecretKind::RdpSshPassword, SecretKind::RdpSshPassphrase), + _ => (SecretKind::IrcSshPassword, SecretKind::IrcSshPassphrase), + }; + let password = (method == "password").then(|| self.secret_fn(id, profile_id, pw_kind, mode, prompt.clone())); + let passphrase = self.secret_fn(id, profile_id, pp_kind, mode, prompt); + let t = SshClient::new(&tp, Callbacks::noop()); + t.connect_tunnel_only(self.host_key_check(id, HostKeyPolicy::Ask), password, Some(passphrase))?; + Ok(t) + } + + pub(super) fn connect_github(self: &Arc, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result { + let gh = p.github.clone().ok_or_else(|| format!("GitHub configuration missing on profile {:?}", p.id))?; + let token = self.secret_fn(id, &p.id, SecretKind::GithubToken, gh.secret_mode, prompt.clone())().map_err(|e| format!("obtaining GitHub token: {e}"))?; + if token.is_empty() { + return err("GitHub token cannot be empty".into()); + } + let mut client = github::Client::new(&gh.base_url, &token); + let login = match client.validate_token() { + Ok(l) => l, + Err(e) => { + let msg = e.to_string().to_lowercase(); + let auth = ["token validation failed", "authentication failed", "http 401", "http 403"].iter().any(|k| msg.contains(k)); + let retry = auth.then(|| prompt("GitHub token did not work. Please enter a valid token:").ok()).flatten().filter(|t| !t.is_empty()); + let Some(t) = retry else { return err(format!("GitHub authentication failed: {e}")) }; + if gh.secret_mode == SecretMode::Saved { + let _ = self.with(id, |s| s.session_secrets.lock().pending.insert(SecretKind::GithubToken.key(&p.id), Zeroizing::new(t.clone()))); + } + client = github::Client::new(&gh.base_url, &t); + client.validate_token().map_err(|e| format!("GitHub authentication failed: {e}"))? + } + }; + let client = Arc::new(client); + if !self.is_current(id, epoch) { + return err(format!("session {id:?} was closed while connecting")); + } + let fetch = { + let (me, sid, client) = (self.clone(), id.to_string(), client.clone()); + move |status: &str| { + me.emit(Event::GithubStatus { session: sid.clone(), status: status.into() }); + let (me2, sid2) = (me.clone(), sid.clone()); + client.fetch_repos_with_progress(move |done, total| { + if total > 0 { + me2.emit(Event::GithubStatus { session: sid2.clone(), status: format!("Loading commit info ({done}/{total})") }); + } + }) + } + }; + let repos = fetch("Loading repositories...").map_err(|e| format!("fetching repositories: {e}"))?; + self.emit(Event::GithubRepos { session: id.into(), login: login.clone(), repos }); + let interval = Duration::from_secs(if gh.refresh_interval_secs < 60 { 300 } else { gh.refresh_interval_secs as u64 }); + let stop = Arc::new(AtomicBool::new(false)); + let (me, sid, stop2) = (self.clone(), id.to_string(), stop.clone()); + std::thread::spawn(move || { + loop { + let deadline = std::time::Instant::now() + interval; + while std::time::Instant::now() < deadline { + if stop2.load(Ordering::SeqCst) { + return; + } + std::thread::sleep(Duration::from_millis(500)); + } + match fetch("Refreshing repositories...") { + Ok(repos) => me.emit(Event::GithubRepos { session: sid.clone(), login: login.clone(), repos }), + Err(e) => me.emit(Event::GithubError { session: sid.clone(), error: e.to_string() }), + } + } + }); + Ok(Client::Github(client, stop)) + } + + pub(super) fn connect_irc(self: &Arc, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result { + let ic = p.irc.clone().ok_or("IRC configuration missing")?; + let server_pass = if ic.server_password_required { + self.secret_fn(id, &p.id, SecretKind::IrcServerPassword, ic.server_password_mode, prompt.clone())() + .map_err(|e| format!("IRC server password: {e}"))? + } else { + String::new() + }; + let ns_pass = if ic.nick_serv_identify { + self.secret_fn(id, &p.id, SecretKind::IrcNickServPassword, ic.nick_serv_mode, prompt.clone())().map_err(|e| format!("NickServ password: {e}"))? + } else { + String::new() + }; + let mut rooms = ic.auto_join_rooms.clone(); + if !p.id.is_empty() { + let last = self.store.irc_last_rooms(&p.id).map_err(|e| format!("loading remembered IRC rooms: {e}"))?; + rooms = crate::storage::dedup_rooms(&[&rooms, &last]); + } + let tunnel = if ic.ssh_tunnel { + Some(self.tunnel( + id, + &p.id, + &ic.ssh_host, + ic.ssh_port, + &ic.ssh_username, + &ic.ssh_auth_method, + &ic.ssh_private_key, + ic.ssh_agent_auth, + ic.ssh_secret_mode, + "irc", + prompt, + )?) + } else { + None + }; + let cb = self.terminal_callbacks(id, epoch); + let (me, sid) = (self.clone(), id.to_string()); + let on_event: irc::EventFn = Arc::new(move |e| me.emit(Event::Irc { session: sid.clone(), event: e })); + let (me, sid, pid) = (self.clone(), id.to_string(), p.id.clone()); + let on_rooms: irc::RoomsChangedFn = Arc::new(move |rooms: Vec| { + if pid.is_empty() { + return; + } + if let Err(e) = me.store.save_irc_last_rooms(&pid, &rooms) { + me.emit(Event::Irc { + session: sid.clone(), + event: irc::Event { + kind: "system".into(), + target: irc::SERVER_TARGET.into(), + text: format!("Could not remember open IRC rooms: {e}"), + timestamp: chrono::Utc::now().timestamp_millis(), + ..Default::default() + }, + }); + } + }); + let connector: Option = tunnel.clone().map(|t| { + let (host, port) = (p.host.clone(), p.port as u16); + Arc::new(move || { + let s = t.open_tunnel(&host, port)?; + s.set_read_timeout(Some(Duration::from_millis(50)))?; + Ok(Box::new(s) as Box) + }) as irc::Connector + }); + let cfg = irc::Config { + server: p.host.clone(), + port: p.port as u16, + nick: ic.nick.clone(), + user: if p.username.is_empty() { ic.nick.clone() } else { p.username.clone() }, + real_name: ic.real_name.clone(), + use_tls: ic.use_tls, + server_pass, + auto_join_rooms: rooms, + nickserv_identify: ic.nick_serv_identify, + nickserv_account: ic.nick_serv_account.clone(), + nickserv_password: ns_pass, + connector, + connect_timeout: Duration::from_secs(p.connect_timeout.max(1) as u64), + on_event, + on_error: cb.on_error, + on_close: cb.on_close, + on_rooms_changed: on_rooms, + }; + let c = irc::Client::new(cfg); + if let Err(e) = c.connect() { + if let Some(t) = tunnel { + t.close(); + } + return Err(e); + } + Ok(Client::Irc(c, tunnel)) + } + + pub(super) fn connect_vnc(self: &Arc, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result { + let vc = p.vnc.clone().ok_or("VNC configuration missing")?; + let host = if vc.vnc_host.is_empty() { p.host.clone() } else { vc.vnc_host.clone() }; + let port = if vc.vnc_port == 0 { 5900 } else { vc.vnc_port } as u16; + let tunnel = if vc.ssh_tunnel { + Some(self.tunnel( + id, + &p.id, + &vc.ssh_host, + vc.ssh_port, + &vc.ssh_username, + &vc.ssh_auth_method, + &vc.ssh_private_key, + vc.ssh_agent_auth, + vc.ssh_secret_mode, + "vnc", + prompt.clone(), + )?) + } else { + None + }; + let password = self.secret_fn(id, &p.id, SecretKind::VncPassword, vc.secret_mode, prompt); + let cb = self.terminal_callbacks(id, epoch); + let (me, sid) = (self.clone(), id.to_string()); + let (me2, sid2) = (self.clone(), id.to_string()); + let clip: clients::vnc::ClipboardFn = Arc::new(move |text| me2.emit(Event::VncClipboard { session: sid2.clone(), text })); + let c = connect_vnc_client( + &host, + port, + &vc, + p.connect_timeout, + password, + tunnel.clone(), + cb, + Arc::new(move || me.emit(Event::DesktopFrame { session: sid.clone() })), + clip, + ); + match c { + Ok(c) => { + self.emit(Event::DesktopReady { session: id.into() }); + Ok(Client::Vnc(c, tunnel)) + } + Err(e) => { + if let Some(t) = tunnel { + t.close(); + } + Err(e) + } + } + } + + pub(super) fn connect_rdp(self: &Arc, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result { + let rc = p.rdp.clone().unwrap_or_else(RdpConfig::defaults); + let port = if p.port == 0 { DEFAULT_RDP_PORT } else { p.port as u16 }; + let tunnel = if rc.ssh_tunnel { + Some(self.tunnel( + id, + &p.id, + &rc.ssh_host, + rc.ssh_port, + &rc.ssh_username, + &rc.ssh_auth_method, + &rc.ssh_private_key, + rc.ssh_agent_auth, + rc.ssh_secret_mode, + "rdp", + prompt.clone(), + )?) + } else { + None + }; + let r = self.rdp_client(id, epoch, p, &rc, port, tunnel.clone(), prompt); + match r { + Ok(c) => { + self.emit(Event::DesktopReady { session: id.into() }); + Ok(Client::Rdp(c, tunnel)) + } + Err(e) => { + if let Some(t) = tunnel { + t.close(); + } + Err(e) + } + } + } + + #[allow(clippy::too_many_arguments)] + pub(super) fn rdp_client( + self: &Arc, + id: &str, + epoch: u64, + p: &Profile, + rc: &RdpConfig, + port: u16, + tunnel: Option>, + prompt: Prompt, + ) -> Result> { + let timeout = Duration::from_secs(p.connect_timeout.max(1) as u64); + let host = p.host.clone(); + let dial: clients::rdp::Dial = match tunnel { + Some(t) => { + let h = host.clone(); + Arc::new(move || t.open_tunnel(&h, port)) + } + None => { + let proxy = clients::proxy::Proxy { + kind: rc.proxy_type.clone(), + host: rc.proxy_host.clone(), + port: rc.proxy_port as u16, + username: rc.proxy_username.clone(), + password: self.secrets.get(&SecretKind::ProxyPassword.key(&p.id)).unwrap_or_default(), + }; + let h = host.clone(); + Arc::new(move || proxy.connect(&h, port, timeout)) + } + }; + let check = self.host_key_check(id, rc.host_key_policy); + let h = host.clone(); + let check_cert: clients::rdp::CertCheck = Arc::new(move |fp: &str| { + check(&HostKeyInfo { host: h.clone(), port, key_type: "rdp-tls".into(), fingerprint: fp.into(), public_key_b64: String::new() }) + }); + // A 0×0 configured size follows the tab, which reports its pixel size like a terminal. + let (width, height) = match (rc.width, rc.height) { + (w, h) if w > 0 && h > 0 => (w as u16, h as u16), + _ => self.with(id, |s| s.pending_resize).ok().flatten().unwrap_or((1280, 800)), + }; + let password = self.secret_fn(id, &p.id, SecretKind::RdpPassword, rc.secret_mode, prompt)()?; + let cb = self.terminal_callbacks(id, epoch); + let (me, sid) = (self.clone(), id.to_string()); + let c = clients::rdp::Client::new(clients::rdp::Config { + host, + username: p.username.clone(), + domain: rc.domain.clone(), + password, + width, + height, + allow_tls_only: rc.allow_tls_only, + dial, + check_cert, + connect_timeout: timeout, + on_frame: Arc::new(move || me.emit(Event::DesktopFrame { session: sid.clone() })), + on_error: cb.on_error, + on_close: cb.on_close, + }); + c.connect()?; + Ok(c) + } +} + +/// Builds and connects a VNC client from a profile's settings, optionally through an SSH tunnel. +#[allow(clippy::too_many_arguments)] +pub(super) fn connect_vnc_client( + host: &str, + port: u16, + vc: &VncConfig, + timeout: i64, + password: SecretFn, + tunnel: Option>, + cb: Callbacks, + on_frame: Arc, + on_clipboard: clients::vnc::ClipboardFn, +) -> Result> { + let connector: Option = tunnel.map(|t| { + let host = host.to_string(); + Arc::new(move || { + let s = t.open_tunnel(&host, port)?; + s.set_read_timeout(Some(Duration::from_millis(50)))?; + Ok(Box::new(s) as Box) + }) as irc::Connector + }); + let mut cfg = clients::vnc::Config { + host: host.into(), + port, + password: None, + shared: vc.shared_session, + view_only: vc.view_only, + quality: vc.quality.clamp(0, 9) as u8, + connector, + connect_timeout: Duration::from_secs(timeout.max(1) as u64), + on_frame, + on_clipboard, + on_error: cb.on_error, + on_close: cb.on_close, + ..Default::default() + }; + let c = clients::vnc::Client::new(cfg.clone()); + match c.connect() { + Ok(()) => Ok(c), + // Ask for the password only when the server wants one. + Err(e) if e.to_string().to_lowercase().contains("password") => { + cfg.password = Some(password()?); + let c = clients::vnc::Client::new(cfg); + c.connect()?; + Ok(c) + } + Err(e) => Err(e), + } +}