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
+413
View File
@@ -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<Self>, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result<Client> {
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<Self>,
id: &str,
profile_id: &str,
host: &str,
port: i64,
user: &str,
method: &str,
key: &str,
agent: bool,
mode: SecretMode,
kind: &str,
prompt: Prompt,
) -> Result<Arc<SshClient>> {
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<Self>, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result<Client> {
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<Self>, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result<Client> {
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<String>| {
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<irc::Connector> = 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<dyn irc::ReadWrite>)
}) 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<Self>, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result<Client> {
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<Self>, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result<Client> {
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<Self>,
id: &str,
epoch: u64,
p: &Profile,
rc: &RdpConfig,
port: u16,
tunnel: Option<Arc<SshClient>>,
prompt: Prompt,
) -> Result<Arc<clients::rdp::Client>> {
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<Arc<SshClient>>,
cb: Callbacks,
on_frame: Arc<dyn Fn() + Send + Sync>,
on_clipboard: clients::vnc::ClipboardFn,
) -> Result<Arc<clients::vnc::Client>> {
let connector: Option<irc::Connector> = 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<dyn irc::ReadWrite>)
}) 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),
}
}