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).
This commit is contained in:
Andy Dixon
2026-10-12 16:57:56 +01:00
parent 15612a629e
commit b149fb3b38
6 changed files with 1763 additions and 1733 deletions
+189
View File
@@ -0,0 +1,189 @@
//! Credential resolution (saved, session-only, ask-every-time), prompts and host-key checks.
use super::*;
pub(super) type SessionSecrets = Arc<Mutex<SecretState>>;
#[derive(Default)]
pub(super) struct SecretState {
/// `SecretMode::SessionOnly` values, asked once per session. Wiped from memory on drop.
pub(super) session_only: HashMap<String, Zeroizing<String>>,
/// `SecretMode::Saved` values typed during this attempt; stored only once it connects, so a
/// typo never ends up saved.
pub(super) pending: HashMap<String, Zeroizing<String>>,
/// Saved values this attempt used.
pub(super) used: HashSet<String>,
/// Saved values the server rejected: the next attempt asks again instead of reusing them.
pub(super) rejected: HashSet<String>,
}
/// 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<String> {
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<Self>, 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<Self>, 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<HostKeyDecision, String> {
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)),
}
})
}
}