Files
nexus/rust/core/src/sessions/protocols.rs
T
Andy Dixon 9805681c2c license: release Nexus Terminal under the GNU AGPL v3
- LICENSE: the GNU Affero General Public License, version 3 (verbatim
  from gnu.org).
- SPDX-License-Identifier: AGPL-3.0-only plus a copyright line on every
  first-party source file (Rust, shell, Python, PHP); vendored crates keep
  their own licenses.
- Cargo manifests carry license/authors/repository; the macOS bundle
  copyright and the About page state the license and link the source.
- Release zips (and the macOS app bundle) include LICENSE.
- README: License section.
2026-10-13 09:46:31 +01:00

459 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Andy Dixon
//! Per-protocol connect: SSH, GitHub, IRC, VNC and RDP, and the SSH tunnels they can use.
use super::*;
impl Manager {
/// Known-hosts rows that are SSH certificate authorities.
pub(super) fn cert_authorities(&self) -> Vec<KnownHost> {
self.store.list_known_hosts().unwrap_or_default().into_iter().filter(|k| k.key_type == crate::known_hosts::CA_KEY_TYPE).collect()
}
// --- 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));
c.set_authorities(self.cert_authorities());
let stored = |k: SecretKind| self.secrets.get(&k.key(&p.id)).map(|v| v.to_string()).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.set_authorities(self.cert_authorities());
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}"))?
.to_string()
} 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}"))?
.to_string()
} else {
String::new()
};
let (sasl_account, sasl_password) = if ic.sasl {
let account = if ic.sasl_account.trim().is_empty() { ic.nick.clone() } else { ic.sasl_account.trim().to_string() };
let pw = self.secret_fn(id, &p.id, SecretKind::IrcSaslPassword, ic.sasl_mode, prompt.clone())().map_err(|e| format!("SASL password: {e}"))?;
(account, pw.to_string())
} else {
(String::new(), 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,
sasl_account,
sasl_password,
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::DesktopClipboard { 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 gateway = (!rc.gateway_host.trim().is_empty()).then(|| rc.gateway_host.trim().to_string());
let dial: clients::rdp::Dial = match (tunnel, gateway) {
(_, Some(gw)) => {
let pw = self.secret_fn(id, &p.id, SecretKind::RdpGatewayPassword, rc.gateway_secret_mode, prompt.clone())()?;
clients::rdp::gateway_dial(gw, rc.gateway_username.clone(), pw, host.clone(), port)
}
(Some(t), None) => {
let h = host.clone();
Arc::new(move || t.open_tunnel(&h, port))
}
(None, 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)).map(|v| v.to_string()).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 (me2, sid2) = (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,
audio: rc.audio,
kerberos_kdc: (!rc.kerberos_kdc.trim().is_empty()).then(|| rc.kerberos_kdc.trim().to_string()),
shared_folder: (!rc.shared_folder.trim().is_empty()).then(|| (rc.shared_folder.trim().into(), "Nexus".into())),
printer: rc.redirect_printer.then(|| {
let (me, sid) = (self.clone(), id.to_string());
let on_printed: clients::rdp_devices::PrintedFn =
Arc::new(move |f: PathBuf| me.emit(Event::Info { session: sid.clone(), message: format!("Print job saved to {}", f.display()) }));
(downloads_dir(), on_printed)
}),
dial,
check_cert,
connect_timeout: timeout,
on_frame: Arc::new(move || me.emit(Event::DesktopFrame { session: sid.clone() })),
on_clipboard: Arc::new(move |text| me2.emit(Event::DesktopClipboard { session: sid2.clone(), text })),
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()?.to_string());
let c = clients::vnc::Client::new(cfg);
c.connect()?;
Ok(c)
}
Err(e) => Err(e),
}
}
/// Where print jobs and similar go: ~/Downloads when it exists, else the home folder.
fn downloads_dir() -> PathBuf {
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from).unwrap_or_else(std::env::temp_dir);
let d = home.join("Downloads");
if d.is_dir() { d } else { home }
}