fix: a panic on one thread no longer takes the whole app down

Every shared lock was std::sync::Mutex with lock().unwrap() (~150 sites),
so a panic in any session's reader or callback poisoned the lock and the
next UI frame panicked too, dropping every open session. Locks are now
parking_lot::Mutex (no poisoning; already in the dependency tree), and a
panic hook reports worker panics as an error toast instead of the session
silently stopping (F3, F4).
This commit is contained in:
Andy Dixon
2026-10-12 09:07:32 +01:00
parent afec95bb36
commit f09300fd95
28 changed files with 252 additions and 221 deletions
+2
View File
@@ -2806,6 +2806,7 @@ dependencies = [
"egui-phosphor", "egui-phosphor",
"image", "image",
"nexus-core", "nexus-core",
"parking_lot",
"regex", "regex",
"rfd", "rfd",
"serde", "serde",
@@ -2827,6 +2828,7 @@ dependencies = [
"image", "image",
"keyring", "keyring",
"libc", "libc",
"parking_lot",
"portable-pty", "portable-pty",
"rand 0.8.8", "rand 0.8.8",
"rusqlite", "rusqlite",
+1
View File
@@ -12,6 +12,7 @@ path = "src/main.rs"
nexus-core = { path = "../core" } nexus-core = { path = "../core" }
eframe = { version = "0.36", default-features = false, features = ["default_fonts", "glow", "wayland", "x11", "accesskit", "persistence"] } eframe = { version = "0.36", default-features = false, features = ["default_fonts", "glow", "wayland", "x11", "accesskit", "persistence"] }
egui = "0.36" egui = "0.36"
parking_lot = "0.12"
egui-phosphor = "0.14" egui-phosphor = "0.14"
rfd = "0.17" rfd = "0.17"
image = { version = "0.25", default-features = false, features = ["png"] } image = { version = "0.25", default-features = false, features = ["png"] }
+12 -4
View File
@@ -11,9 +11,10 @@ use egui::{Key, KeyboardShortcut, Modifiers, Ui};
use nexus_core::models::{Profile, Protocol, SessionInfo, SessionState}; use nexus_core::models::{Profile, Protocol, SessionInfo, SessionState};
use nexus_core::service::Service; use nexus_core::service::Service;
use nexus_core::sessions::{Event, HostKeyDecision}; use nexus_core::sessions::{Event, HostKeyDecision};
use parking_lot::Mutex;
use std::collections::VecDeque; use std::collections::VecDeque;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::{Arc, Mutex, mpsc}; use std::sync::{Arc, mpsc};
pub struct Options { pub struct Options {
pub screenshot: Option<PathBuf>, pub screenshot: Option<PathBuf>,
@@ -37,7 +38,14 @@ pub struct Options {
static POSTED: Mutex<Vec<String>> = Mutex::new(Vec::new()); static POSTED: Mutex<Vec<String>> = Mutex::new(Vec::new());
pub fn post_error(e: String) { pub fn post_error(e: String) {
POSTED.lock().unwrap().push(e); POSTED.lock().push(e);
}
/// For the panic hook: the panicking thread may already hold the lock.
pub fn post_error_nonblocking(e: String) {
if let Some(mut p) = POSTED.try_lock() {
p.push(e);
}
} }
/// A credential or host-key question from a connecting session. /// A credential or host-key question from a connecting session.
@@ -141,7 +149,7 @@ impl App {
let ctx = cc.egui_ctx.clone(); let ctx = cc.egui_ctx.clone();
let tx = Mutex::new(tx); let tx = Mutex::new(tx);
svc.sessions.set_sink(Arc::new(move |e| { svc.sessions.set_sink(Arc::new(move |e| {
let _ = tx.lock().unwrap().send(e); let _ = tx.lock().send(e);
ctx.request_repaint(); ctx.request_repaint();
})); }));
let s = Shared::new(svc.clone(), cc.egui_ctx.clone()); let s = Shared::new(svc.clone(), cc.egui_ctx.clone());
@@ -405,7 +413,7 @@ impl App {
// --- events --------------------------------------------------------------------------- // --- events ---------------------------------------------------------------------------
fn drain_events(&mut self) { fn drain_events(&mut self) {
for e in POSTED.lock().unwrap().drain(..) { for e in POSTED.lock().drain(..) {
self.s.error(e); self.s.error(e);
} }
while let Ok(e) = self.rx.try_recv() { while let Ok(e) = self.rx.try_recv() {
+3 -3
View File
@@ -370,13 +370,13 @@ pub fn status_bar(app: &mut App, ui: &mut Ui) {
}); });
} }
static LATENCY: std::sync::Mutex<Option<(String, Result<i64, String>)>> = std::sync::Mutex::new(None); static LATENCY: parking_lot::Mutex<Option<(String, Result<i64, String>)>> = parking_lot::Mutex::new(None);
/// Pings the active SSH session every 10 s for the status-bar latency. /// Pings the active SSH session every 10 s for the status-bar latency.
fn latency_tick(app: &mut App) { fn latency_tick(app: &mut App) {
let id = app.s.active.clone(); let id = app.s.active.clone();
let ssh = id.as_deref().and_then(|a| app.s.pane(a)).is_some_and(|p| p.info.protocol == Protocol::Ssh); let ssh = id.as_deref().and_then(|a| app.s.pane(a)).is_some_and(|p| p.info.protocol == Protocol::Ssh);
if let Some(r) = LATENCY.lock().unwrap().take() { if let Some(r) = LATENCY.lock().take() {
app.latency = Some(r); app.latency = Some(r);
} }
let Some(id) = id.filter(|_| ssh) else { return }; let Some(id) = id.filter(|_| ssh) else { return };
@@ -395,7 +395,7 @@ fn latency_tick(app: &mut App) {
let (svc, ctx) = (app.s.svc.clone(), app.s.ctx.clone()); let (svc, ctx) = (app.s.svc.clone(), app.s.ctx.clone());
std::thread::spawn(move || { std::thread::spawn(move || {
let r = svc.sessions.ping(&id).map_err(|e| e.to_string()); let r = svc.sessions.ping(&id).map_err(|e| e.to_string());
*LATENCY.lock().unwrap() = Some((id, r)); *LATENCY.lock() = Some((id, r));
ctx.request_repaint(); ctx.request_repaint();
}); });
} }
+5 -4
View File
@@ -7,7 +7,8 @@ use crate::widgets;
use egui::{Align, Key, Layout, RichText}; use egui::{Align, Key, Layout, RichText};
use egui_phosphor::regular as icon; use egui_phosphor::regular as icon;
use nexus_core::models::{ForwardDirection, PortForward, new_id}; use nexus_core::models::{ForwardDirection, PortForward, new_id};
use std::sync::{Arc, Mutex}; use parking_lot::Mutex;
use std::sync::Arc;
struct Form { struct Form {
remote: bool, remote: bool,
@@ -49,7 +50,7 @@ impl Forwards {
} }
pub fn ui(&mut self, ctx: &egui::Context, s: &mut Shared) { pub fn ui(&mut self, ctx: &egui::Context, s: &mut Shared) {
for started in std::mem::take(&mut *self.done.lock().unwrap()) { for started in std::mem::take(&mut *self.done.lock()) {
if started { if started {
s.success("Forward started"); s.success("Forward started");
self.form.bind_port.clear(); self.form.bind_port.clear();
@@ -131,7 +132,7 @@ impl Forwards {
if let Some(id) = stop { if let Some(id) = stop {
let (svc, sid, done) = (s.svc.clone(), sid.clone(), self.done.clone()); let (svc, sid, done) = (s.svc.clone(), sid.clone(), self.done.clone());
s.spawn(move || svc.sessions.stop_forward(&sid, &id).map_err(|e| format!("Failed to stop: {e}").into()), move |_| done.lock().unwrap().push(false)); s.spawn(move || svc.sessions.stop_forward(&sid, &id).map_err(|e| format!("Failed to stop: {e}").into()), move |_| done.lock().push(false));
} }
if start { if start {
let f = &self.form; let f = &self.form;
@@ -153,7 +154,7 @@ impl Forwards {
let (svc, done) = (s.svc.clone(), self.done.clone()); let (svc, done) = (s.svc.clone(), self.done.clone());
s.spawn( s.spawn(
move || svc.sessions.start_forward(&sid, rule).map_err(|e| format!("Failed to start forward: {e}").into()), move || svc.sessions.start_forward(&sid, rule).map_err(|e| format!("Failed to start forward: {e}").into()),
move |_| done.lock().unwrap().push(true), move |_| done.lock().push(true),
); );
} }
_ => s.error("Bind port and destination port are required"), _ => s.error("Bind port and destination port are required"),
+5 -4
View File
@@ -8,7 +8,8 @@ use chrono::{DateTime, Utc};
use egui::{Color32, RichText, Sense, Ui, vec2}; use egui::{Color32, RichText, Sense, Ui, vec2};
use egui_phosphor::regular as icon; use egui_phosphor::regular as icon;
use nexus_core::models::GitHubRepo; use nexus_core::models::GitHubRepo;
use std::sync::{Arc, Mutex}; use parking_lot::Mutex;
use std::sync::Arc;
enum Msg { enum Msg {
Repos(Vec<GitHubRepo>), Repos(Vec<GitHubRepo>),
@@ -175,7 +176,7 @@ impl GithubView {
Ok(r) => Msg::Repos(r), Ok(r) => Msg::Repos(r),
Err(e) => Msg::RefreshFailed(e.to_string()), Err(e) => Msg::RefreshFailed(e.to_string()),
}; };
inbox.lock().unwrap().push(msg); inbox.lock().push(msg);
ctx.request_repaint(); ctx.request_repaint();
}); });
} }
@@ -193,13 +194,13 @@ impl GithubView {
Ok(()) => Msg::Downloaded(full, path.display().to_string()), Ok(()) => Msg::Downloaded(full, path.display().to_string()),
Err(e) => Msg::DownloadFailed(e.to_string()), Err(e) => Msg::DownloadFailed(e.to_string()),
}; };
inbox.lock().unwrap().push(msg); inbox.lock().push(msg);
ctx.request_repaint(); ctx.request_repaint();
}); });
} }
pub fn ui(&mut self, ui: &mut Ui, s: &mut Shared) { pub fn ui(&mut self, ui: &mut Ui, s: &mut Shared) {
let msgs = std::mem::take(&mut *self.inbox.lock().unwrap()); let msgs = std::mem::take(&mut *self.inbox.lock());
for m in msgs { for m in msgs {
match m { match m {
Msg::Repos(r) => { Msg::Repos(r) => {
+10
View File
@@ -33,6 +33,16 @@ use nexus_core::service::Service;
use std::sync::Arc; use std::sync::Arc;
fn main() -> eframe::Result { fn main() -> eframe::Result {
// A panic on a worker thread (a session's reader, a transfer) ends only that thread; say so in
// the UI rather than letting the session just stop.
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
default_hook(info);
let what =
info.payload().downcast_ref::<&str>().copied().or_else(|| info.payload().downcast_ref::<String>().map(String::as_str)).unwrap_or("unknown error");
let thread = std::thread::current().name().unwrap_or("worker").to_string();
app::post_error_nonblocking(format!("Internal error in {thread} thread: {what}. Other sessions are unaffected; reconnect the affected one."));
}));
let mut opts = app::Options { let mut opts = app::Options {
screenshot: None, screenshot: None,
theme: None, theme: None,
+4 -3
View File
@@ -11,9 +11,10 @@ use nexus_core::clients::serial;
use nexus_core::keyfiles; use nexus_core::keyfiles;
use nexus_core::models::*; use nexus_core::models::*;
use nexus_core::validation::{ValidationError, validate_profile}; use nexus_core::validation::{ValidationError, validate_profile};
use parking_lot::Mutex;
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap};
use std::path::Path; use std::path::Path;
use std::sync::{Arc, Mutex}; use std::sync::Arc;
type KeyChecks = HashMap<String, Result<&'static str, &'static str>>; type KeyChecks = HashMap<String, Result<&'static str, &'static str>>;
@@ -184,7 +185,7 @@ impl ProfileEditor {
/// Drawn inside the right dock while open. /// Drawn inside the right dock while open.
pub fn ui(&mut self, ui: &mut Ui, s: &mut Shared) { pub fn ui(&mut self, ui: &mut Ui, s: &mut Shared) {
let t = Tokens::get(ui.ctx()); let t = Tokens::get(ui.ctx());
if let Some(r) = self.test.lock().unwrap().take() { if let Some(r) = self.test.lock().take() {
self.testing = false; self.testing = false;
match r { match r {
Ok(ms) => s.success(format!("Reachable — {ms} ms")), Ok(ms) => s.success(format!("Reachable — {ms} ms")),
@@ -336,7 +337,7 @@ impl ProfileEditor {
let (slot, svc, ctx) = (self.test.clone(), s.svc.clone(), s.ctx.clone()); let (slot, svc, ctx) = (self.test.clone(), s.svc.clone(), s.ctx.clone());
std::thread::spawn(move || { std::thread::spawn(move || {
let r = svc.test_connection(&host, port).map_err(|e| e.to_string()); let r = svc.test_connection(&host, port).map_err(|e| e.to_string());
*slot.lock().unwrap() = Some(r); *slot.lock() = Some(r);
ctx.request_repaint(); ctx.request_repaint();
}); });
} }
+4 -3
View File
@@ -9,7 +9,8 @@ use egui::{RichText, Ui, vec2};
use egui_phosphor::regular as icon; use egui_phosphor::regular as icon;
use nexus_core::models::{AppPreferences, KnownHost, Protocol}; use nexus_core::models::{AppPreferences, KnownHost, Protocol};
use nexus_core::updater::UpdateInfo; use nexus_core::updater::UpdateInfo;
use std::sync::{Arc, Mutex}; use parking_lot::Mutex;
use std::sync::Arc;
/// A result from a worker thread, polled each frame (`Some` while the work is in flight). /// A result from a worker thread, polled each frame (`Some` while the work is in flight).
pub struct Job<T>(Arc<Mutex<Option<T>>>); pub struct Job<T>(Arc<Mutex<Option<T>>>);
@@ -20,14 +21,14 @@ impl<T: Send + 'static> Job<T> {
let (out, ctx) = (slot.clone(), ctx.clone()); let (out, ctx) = (slot.clone(), ctx.clone());
std::thread::spawn(move || { std::thread::spawn(move || {
let v = f(); let v = f();
*out.lock().unwrap() = Some(v); *out.lock() = Some(v);
ctx.request_repaint(); ctx.request_repaint();
}); });
Job(slot) Job(slot)
} }
pub fn take(&self) -> Option<T> { pub fn take(&self) -> Option<T> {
self.0.lock().unwrap().take() self.0.lock().take()
} }
} }
+5 -4
View File
@@ -13,9 +13,10 @@ use nexus_core::clients::sftp::{self as rsftp, FileEntry, Progress};
use nexus_core::keyfiles::safe_sftp_upload_name; use nexus_core::keyfiles::safe_sftp_upload_name;
use nexus_core::models::Protocol; use nexus_core::models::Protocol;
use nexus_core::service::Service; use nexus_core::service::Service;
use parking_lot::Mutex;
use std::collections::{BTreeMap, HashMap, HashSet}; use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::Arc;
/// Worker results, applied on the UI thread. /// Worker results, applied on the UI thread.
enum Done { enum Done {
@@ -47,7 +48,7 @@ struct Inbox(Arc<Mutex<Vec<Done>>>, egui::Context);
impl Inbox { impl Inbox {
fn push(&self, d: Done) { fn push(&self, d: Done) {
self.0.lock().unwrap().push(d); self.0.lock().push(d);
self.1.request_repaint(); self.1.request_repaint();
} }
} }
@@ -250,7 +251,7 @@ impl Sftp {
let tr = self.transfers.remove(i); let tr = self.transfers.remove(i);
if self.open_for.as_deref() != Some(session) { if self.open_for.as_deref() != Some(session) {
let verb = if tr.upload { "↑ Uploaded" } else { "↓ Downloaded" }; let verb = if tr.upload { "↑ Uploaded" } else { "↓ Downloaded" };
self.inbox.lock().unwrap().push(Done::Toast(format!("{verb} {}", base_name(&tr.path)))); self.inbox.lock().push(Done::Toast(format!("{verb} {}", base_name(&tr.path))));
} }
} }
} }
@@ -269,7 +270,7 @@ impl Sftp {
} }
fn drain(&mut self, s: &mut Shared) { fn drain(&mut self, s: &mut Shared) {
let items = std::mem::take(&mut *self.inbox.lock().unwrap()); let items = std::mem::take(&mut *self.inbox.lock());
for d in items { for d in items {
match d { match d {
Done::Listed { session, path, files } => { Done::Listed { session, path, files } => {
+5 -4
View File
@@ -12,7 +12,8 @@ use alacritty_terminal::term::test::TermSize;
use alacritty_terminal::term::{Config, Term, TermMode}; use alacritty_terminal::term::{Config, Term, TermMode};
use alacritty_terminal::vte::ansi::{self, Color, CursorShape, NamedColor, Processor, StdSyncHandler}; use alacritty_terminal::vte::ansi::{self, Color, CursorShape, NamedColor, Processor, StdSyncHandler};
use egui::{Color32, Event, FontId, Key, Modifiers, PointerButton, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2}; use egui::{Color32, Event, FontId, Key, Modifiers, PointerButton, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2};
use std::sync::{Arc, Mutex}; use parking_lot::Mutex;
use std::sync::Arc;
#[derive(Clone, Default)] #[derive(Clone, Default)]
struct Listener(Arc<Mutex<Vec<TermEvent>>>); struct Listener(Arc<Mutex<Vec<TermEvent>>>);
@@ -20,7 +21,7 @@ struct Listener(Arc<Mutex<Vec<TermEvent>>>);
impl EventListener for Listener { impl EventListener for Listener {
fn send_event(&self, e: TermEvent) { fn send_event(&self, e: TermEvent) {
if matches!(e, TermEvent::PtyWrite(_) | TermEvent::Title(_) | TermEvent::Bell | TermEvent::ClipboardStore(..)) { if matches!(e, TermEvent::PtyWrite(_) | TermEvent::Title(_) | TermEvent::Bell | TermEvent::ClipboardStore(..)) {
self.0.lock().unwrap().push(e); self.0.lock().push(e);
} }
} }
} }
@@ -294,7 +295,7 @@ impl TermView {
self.mouse(ui, &resp, origin, cw, ch, &mut out); self.mouse(ui, &resp, origin, cw, ch, &mut out);
// Terminal-originated events. // Terminal-originated events.
for e in std::mem::take(&mut *self.events.0.lock().unwrap()) { for e in std::mem::take(&mut *self.events.0.lock()) {
match e { match e {
TermEvent::PtyWrite(s) => out.reply.extend_from_slice(s.as_bytes()), TermEvent::PtyWrite(s) => out.reply.extend_from_slice(s.as_bytes()),
TermEvent::Title(t) => out.title = Some(t), TermEvent::Title(t) => out.title = Some(t),
@@ -918,7 +919,7 @@ mod tests {
fn replies_to_device_attributes() { fn replies_to_device_attributes() {
let mut t = TermView::new(10, 14.0, CursorStyle::Block); let mut t = TermView::new(10, 14.0, CursorStyle::Block);
t.feed(b"\x1b[6n"); t.feed(b"\x1b[6n");
let ev = t.events.0.lock().unwrap(); let ev = t.events.0.lock();
assert!(ev.iter().any(|e| matches!(e, TermEvent::PtyWrite(s) if s == "\x1b[1;1R"))); assert!(ev.iter().any(|e| matches!(e, TermEvent::PtyWrite(s) if s == "\x1b[1;1R")));
} }
+1 -1
View File
@@ -218,7 +218,7 @@ impl VncView {
fn upload(&mut self, ui: &Ui) { fn upload(&mut self, ui: &Ui) {
let Some(c) = &self.client else { return }; let Some(c) = &self.client else { return };
let fb = c.framebuffer(); let fb = c.framebuffer();
let mut fb = fb.lock().unwrap_or_else(|e| e.into_inner()); let mut fb = fb.lock();
if !fb.dirty || fb.width == 0 || fb.height == 0 { if !fb.dirty || fb.width == 0 || fb.height == 0 {
return; return;
} }
+1
View File
@@ -22,6 +22,7 @@ sha2 = "0.11.0"
hmac = "0.13.0" hmac = "0.13.0"
sha1 = "0.11.0" sha1 = "0.11.0"
zeroize = "1" zeroize = "1"
parking_lot = "0.12"
portable-pty = "0.9.0" portable-pty = "0.9.0"
serialport = { version = "4", default-features = false } serialport = { version = "4", default-features = false }
des = "0.9" des = "0.9"
+8 -7
View File
@@ -355,7 +355,8 @@ fn copy_limited_archive(dst: &mut impl Write, src: impl Read, max_bytes: u64) ->
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::sync::{Arc, Mutex}; use parking_lot::Mutex;
use std::sync::Arc;
#[test] #[test]
fn read_limited_rejects_oversized_response() { fn read_limited_rejects_oversized_response() {
@@ -410,7 +411,7 @@ mod tests {
std::thread::spawn(move || { std::thread::spawn(move || {
for req in server.incoming_requests() { for req in server.incoming_requests() {
let header = |name: &'static str| req.headers().iter().find(|h| h.field.equiv(name)).map(|h| h.value.to_string()); let header = |name: &'static str| req.headers().iter().find(|h| h.field.equiv(name)).map(|h| h.value.to_string());
log.lock().unwrap().push((req.url().to_string(), header("Authorization"), header("User-Agent"))); log.lock().push((req.url().to_string(), header("Authorization"), header("User-Agent")));
let (status, body) = handler(req.url()); let (status, body) = handler(req.url());
let _ = req.respond(tiny_http::Response::from_string(body).with_status_code(status)); let _ = req.respond(tiny_http::Response::from_string(body).with_status_code(status));
} }
@@ -422,7 +423,7 @@ mod tests {
fn validate_token_returns_login_and_reports_status() { fn validate_token_returns_login_and_reports_status() {
let (base, seen) = serve(|_| (200, r#"{"login":"octocat"}"#.into())); let (base, seen) = serve(|_| (200, r#"{"login":"octocat"}"#.into()));
assert_eq!(Client::new(&base, "tok").validate_token().unwrap(), "octocat"); assert_eq!(Client::new(&base, "tok").validate_token().unwrap(), "octocat");
let (url, auth, ua) = seen.lock().unwrap()[0].clone(); let (url, auth, ua) = seen.lock()[0].clone();
assert_eq!(url, "/user"); assert_eq!(url, "/user");
assert_eq!(auth.as_deref(), Some("Bearer tok")); assert_eq!(auth.as_deref(), Some("Bearer tok"));
assert_eq!(ua.as_deref(), Some(USER_AGENT)); assert_eq!(ua.as_deref(), Some(USER_AGENT));
@@ -459,7 +460,7 @@ mod tests {
} }
}); });
let calls = Mutex::new(Vec::new()); let calls = Mutex::new(Vec::new());
let repos = Client::new(&base, "t").fetch_repos_with_progress(|done, total| calls.lock().unwrap().push((done, total))).unwrap(); let repos = Client::new(&base, "t").fetch_repos_with_progress(|done, total| calls.lock().push((done, total))).unwrap();
assert_eq!(repos.len(), 101); assert_eq!(repos.len(), 101);
assert_eq!(repos[0].full_name, "me/r100"); assert_eq!(repos[0].full_name, "me/r100");
assert_eq!(repos[0].pushed_at, "2025-06-01T12:00:00Z"); assert_eq!(repos[0].pushed_at, "2025-06-01T12:00:00Z");
@@ -472,12 +473,12 @@ mod tests {
assert!(r0.private && r0.description.is_empty() && r0.language.is_empty()); assert!(r0.private && r0.description.is_empty() && r0.language.is_empty());
assert!(repos.iter().filter(|r| r.latest_committer.is_empty()).count() == 99); assert!(repos.iter().filter(|r| r.latest_committer.is_empty()).count() == 99);
let mut calls = calls.into_inner().unwrap(); let mut calls = calls.into_inner();
assert_eq!(calls[0], (0, 101)); assert_eq!(calls[0], (0, 101));
calls.sort(); calls.sort();
assert_eq!(calls.last(), Some(&(101, 101))); assert_eq!(calls.last(), Some(&(101, 101)));
assert_eq!(calls.len(), 102); assert_eq!(calls.len(), 102);
assert_eq!(seen.lock().unwrap().iter().filter(|s| s.0.starts_with("/user/repos")).count(), 2); assert_eq!(seen.lock().iter().filter(|s| s.0.starts_with("/user/repos")).count(), 2);
} }
#[test] #[test]
@@ -505,7 +506,7 @@ mod tests {
assert_eq!(std::fs::read(&dest).unwrap(), b"ZIPDATA"); assert_eq!(std::fs::read(&dest).unwrap(), b"ZIPDATA");
let err = client.download_repo_archive("me/r", "", &dest).unwrap_err().to_string(); let err = client.download_repo_archive("me/r", "", &dest).unwrap_err().to_string();
assert_eq!(err, "GitHub API returned HTTP 404: missing"); assert_eq!(err, "GitHub API returned HTTP 404: missing");
assert_eq!(seen.lock().unwrap()[1].0, "/repos/me/r/zipball"); assert_eq!(seen.lock()[1].0, "/repos/me/r/zipball");
assert!(client.download_repo_archive("bad", "", &dest).is_err()); assert!(client.download_repo_archive("bad", "", &dest).is_err());
std::fs::remove_dir_all(dir).unwrap(); std::fs::remove_dir_all(dir).unwrap();
} }
+18 -17
View File
@@ -5,11 +5,12 @@
//! optionally wrapped in TLS). It alternates between short reads and draining the outgoing //! optionally wrapped in TLS). It alternates between short reads and draining the outgoing
//! queue, so the transport never has to be split. //! queue, so the transport never has to be split.
use parking_lot::{Mutex, MutexGuard};
use std::collections::HashMap; use std::collections::HashMap;
use std::io::{ErrorKind, Read, Write}; use std::io::{ErrorKind, Read, Write};
use std::net::{TcpStream, ToSocketAddrs}; use std::net::{TcpStream, ToSocketAddrs};
use std::sync::Arc;
use std::sync::mpsc::{self, Receiver, Sender}; use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -197,7 +198,7 @@ impl Client {
} }
fn st(&self) -> MutexGuard<'_, State> { fn st(&self) -> MutexGuard<'_, State> {
self.state.lock().unwrap_or_else(|e| e.into_inner()) self.state.lock()
} }
/// Connects and blocks until registration succeeds (RPL_WELCOME), fails, or times out. /// Connects and blocks until registration succeeds (RPL_WELCOME), fails, or times out.
@@ -205,7 +206,7 @@ impl Client {
let (ready_tx, ready_rx) = mpsc::channel(); let (ready_tx, ready_rx) = mpsc::channel();
let (out_tx, out_rx) = mpsc::channel(); let (out_tx, out_rx) = mpsc::channel();
self.st().ready = Some(ready_tx); self.st().ready = Some(ready_tx);
*self.out.lock().unwrap_or_else(|e| e.into_inner()) = Some(out_tx); *self.out.lock() = Some(out_tx);
let client = self.clone(); let client = self.clone();
std::thread::spawn(move || client.io_thread(out_rx)); std::thread::spawn(move || client.io_thread(out_rx));
@@ -234,7 +235,7 @@ impl Client {
} }
fn queue(&self, out: Out) -> bool { fn queue(&self, out: Out) -> bool {
let guard = self.out.lock().unwrap_or_else(|e| e.into_inner()); let guard = self.out.lock();
guard.as_ref().is_some_and(|tx| tx.send(out).is_ok()) guard.as_ref().is_some_and(|tx| tx.send(out).is_ok())
} }
@@ -471,7 +472,7 @@ impl Client {
fn io_thread(self: Arc<Self>, out_rx: Receiver<Out>) { fn io_thread(self: Arc<Self>, out_rx: Receiver<Out>) {
let result = self.open_transport().and_then(|stream| self.run(stream, &out_rx)); let result = self.open_transport().and_then(|stream| self.run(stream, &out_rx));
*self.out.lock().unwrap_or_else(|e| e.into_inner()) = None; *self.out.lock() = None;
let (was_closing, was_connected, ready) = { let (was_closing, was_connected, ready) = {
let mut st = self.st(); let mut st = self.st();
let r = (st.closing, st.connected, st.ready.take()); let r = (st.closing, st.connected, st.ready.take());
@@ -1037,13 +1038,13 @@ mod tests {
fn remember_room_membership_tracks_confirmed_channels() { fn remember_room_membership_tracks_confirmed_channels() {
let updates = Arc::new(Mutex::new(Vec::new())); let updates = Arc::new(Mutex::new(Vec::new()));
let log = updates.clone(); let log = updates.clone();
let client = Client::new(Config { on_rooms_changed: Arc::new(move |rooms| log.lock().unwrap().push(rooms.join(","))), ..Default::default() }); let client = Client::new(Config { on_rooms_changed: Arc::new(move |rooms| log.lock().push(rooms.join(","))), ..Default::default() });
client.remember_room_membership("#One", true); client.remember_room_membership("#One", true);
client.remember_room_membership("#two", true); client.remember_room_membership("#two", true);
client.remember_room_membership("#one", true); client.remember_room_membership("#one", true);
client.remember_room_membership("#missing", false); client.remember_room_membership("#missing", false);
client.remember_room_membership("#ONE", false); client.remember_room_membership("#ONE", false);
assert_eq!(updates.lock().unwrap().join("|"), "#One|#One,#two|#two"); assert_eq!(updates.lock().join("|"), "#One|#One,#two|#two");
assert_eq!(client.st().room_names, vec!["#two"]); assert_eq!(client.st().room_names, vec!["#two"]);
} }
@@ -1109,9 +1110,9 @@ mod tests {
nick: "me".into(), nick: "me".into(),
connect_timeout: Duration::from_secs(5), connect_timeout: Duration::from_secs(5),
on_event: Arc::new(move |e| { on_event: Arc::new(move |e| {
let _ = tx.lock().unwrap().send(e); let _ = tx.lock().send(e);
}), }),
on_close: Arc::new(move || *c.lock().unwrap() += 1), on_close: Arc::new(move || *c.lock() += 1),
..cfg ..cfg
}); });
(client, listener, rx, closes) (client, listener, rx, closes)
@@ -1224,11 +1225,11 @@ mod tests {
client.close(); client.close();
srv.expect("QUIT :Nexus Terminal disconnected"); srv.expect("QUIT :Nexus Terminal disconnected");
let deadline = Instant::now() + Duration::from_secs(5); let deadline = Instant::now() + Duration::from_secs(5);
while *closes.lock().unwrap() == 0 && Instant::now() < deadline { while *closes.lock() == 0 && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10)); std::thread::sleep(Duration::from_millis(10));
} }
std::thread::sleep(Duration::from_millis(100)); std::thread::sleep(Duration::from_millis(100));
assert_eq!(*closes.lock().unwrap(), 1); assert_eq!(*closes.lock(), 1);
assert!(client.handle_input("#a", "late").is_err()); assert!(client.handle_input("#a", "late").is_err());
} }
@@ -1236,7 +1237,7 @@ mod tests {
fn remote_close_reports_error_and_close() { fn remote_close_reports_error_and_close() {
let errors = Arc::new(Mutex::new(Vec::new())); let errors = Arc::new(Mutex::new(Vec::new()));
let log = errors.clone(); let log = errors.clone();
let (client, listener, rx, closes) = harness(Config { on_error: Arc::new(move |e| log.lock().unwrap().push(e)), ..Default::default() }); let (client, listener, rx, closes) = harness(Config { on_error: Arc::new(move |e| log.lock().push(e)), ..Default::default() });
let c = client.clone(); let c = client.clone();
let connecting = std::thread::spawn(move || c.connect()); let connecting = std::thread::spawn(move || c.connect());
let mut srv = accept(&listener); let mut srv = accept(&listener);
@@ -1248,8 +1249,8 @@ mod tests {
drop(srv); drop(srv);
assert_eq!(wait_for(&rx, |e| e.kind == "error").text, "connection closed by server"); assert_eq!(wait_for(&rx, |e| e.kind == "error").text, "connection closed by server");
std::thread::sleep(Duration::from_millis(100)); std::thread::sleep(Duration::from_millis(100));
assert_eq!(*errors.lock().unwrap(), vec!["connection closed by server"]); assert_eq!(*errors.lock(), vec!["connection closed by server"]);
assert_eq!(*closes.lock().unwrap(), 1); assert_eq!(*closes.lock(), 1);
} }
#[test] #[test]
@@ -1259,7 +1260,7 @@ mod tests {
let err = client.connect().unwrap_err().to_string(); let err = client.connect().unwrap_err().to_string();
assert_eq!(err, "IRC connection timed out after 300ms"); assert_eq!(err, "IRC connection timed out after 300ms");
drop(listener); drop(listener);
assert_eq!(*closes.lock().unwrap(), 0); assert_eq!(*closes.lock(), 0);
} }
#[test] #[test]
@@ -1273,7 +1274,7 @@ mod tests {
nick: "me".into(), nick: "me".into(),
connect_timeout: Duration::from_secs(5), connect_timeout: Duration::from_secs(5),
connector: Some(Arc::new(move || { connector: Some(Arc::new(move || {
*flag.lock().unwrap() = true; *flag.lock() = true;
let s = TcpStream::connect(addr)?; let s = TcpStream::connect(addr)?;
s.set_read_timeout(Some(Duration::from_millis(20)))?; s.set_read_timeout(Some(Duration::from_millis(20)))?;
Ok(Box::new(s) as Box<dyn ReadWrite>) Ok(Box::new(s) as Box<dyn ReadWrite>)
@@ -1286,7 +1287,7 @@ mod tests {
srv.expect("USER me * * me"); srv.expect("USER me * * me");
srv.send(":srv 001 me :Welcome"); srv.send(":srv 001 me :Welcome");
connecting.join().unwrap().unwrap(); connecting.join().unwrap().unwrap();
assert!(*used.lock().unwrap()); assert!(*used.lock());
client.handle_input("", "/quit gone").unwrap(); client.handle_input("", "/quit gone").unwrap();
srv.expect("QUIT gone"); srv.expect("QUIT gone");
} }
+14 -16
View File
@@ -2,10 +2,11 @@
use super::Callbacks; use super::Callbacks;
use crate::Result; use crate::Result;
use parking_lot::Mutex;
use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system}; use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system};
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::{Arc, Mutex}; use std::sync::Arc;
pub fn default_shell() -> String { pub fn default_shell() -> String {
if cfg!(windows) { if cfg!(windows) {
@@ -36,7 +37,7 @@ impl LocalShell {
} }
pub fn start(self: &Arc<Self>) -> Result<()> { pub fn start(self: &Arc<Self>) -> Result<()> {
let mut st = self.state.lock().unwrap(); let mut st = self.state.lock();
if st.4 { if st.4 {
return Err("shell already closed".into()); return Err("shell already closed".into());
} }
@@ -77,7 +78,7 @@ impl LocalShell {
std::thread::Builder::new().name("nexus-local-wait".into()).spawn(move || { std::thread::Builder::new().name("nexus-local-wait".into()).spawn(move || {
let _ = child.wait(); let _ = child.wait();
let was_closed = { let was_closed = {
let mut st = me.state.lock().unwrap(); let mut st = me.state.lock();
let was = st.4; let was = st.4;
st.4 = true; st.4 = true;
st.0 = None; st.0 = None;
@@ -94,14 +95,14 @@ impl LocalShell {
if data.iter().any(|b| matches!(b, b'\r' | b'\n' | 4)) { if data.iter().any(|b| matches!(b, b'\r' | b'\n' | 4)) {
self.capture_cwd(); self.capture_cwd();
} }
let mut st = self.state.lock().unwrap(); let mut st = self.state.lock();
let r = st.0.as_mut().ok_or("shell not running")?; let r = st.0.as_mut().ok_or("shell not running")?;
r.writer.write_all(data)?; r.writer.write_all(data)?;
Ok(()) Ok(())
} }
pub fn resize(&self, cols: u16, rows: u16) -> Result<()> { pub fn resize(&self, cols: u16, rows: u16) -> Result<()> {
let mut st = self.state.lock().unwrap(); let mut st = self.state.lock();
if cols > 0 { if cols > 0 {
st.2 = cols; st.2 = cols;
} }
@@ -117,7 +118,7 @@ impl LocalShell {
/// Idempotent; does not fire on_close (matches Go: an explicit close is not an exit). /// Idempotent; does not fire on_close (matches Go: an explicit close is not an exit).
pub fn close(&self) { pub fn close(&self) {
let mut st = self.state.lock().unwrap(); let mut st = self.state.lock();
if st.4 { if st.4 {
return; return;
} }
@@ -129,13 +130,13 @@ impl LocalShell {
/// The last directory the shell was seen in (used to restart in the same place). /// The last directory the shell was seen in (used to restart in the same place).
pub fn working_directory(&self) -> PathBuf { pub fn working_directory(&self) -> PathBuf {
self.state.lock().unwrap().1.clone() self.state.lock().1.clone()
} }
fn capture_cwd(&self) { fn capture_cwd(&self) {
let pid = self.state.lock().unwrap().0.as_ref().and_then(|r| r.pid); let pid = self.state.lock().0.as_ref().and_then(|r| r.pid);
if let Some(dir) = pid.and_then(process_cwd).filter(|d| d.is_dir()) { if let Some(dir) = pid.and_then(process_cwd).filter(|d| d.is_dir()) {
self.state.lock().unwrap().1 = dir; self.state.lock().1 = dir;
} }
} }
} }
@@ -165,22 +166,19 @@ mod tests {
let seen: Arc<Mutex<Vec<u8>>> = Arc::default(); let seen: Arc<Mutex<Vec<u8>>> = Arc::default();
let closed = Arc::new(Mutex::new(0)); let closed = Arc::new(Mutex::new(0));
let (s2, c2) = (seen.clone(), closed.clone()); let (s2, c2) = (seen.clone(), closed.clone());
let cb = Callbacks { let cb =
on_data: Arc::new(move |d| s2.lock().unwrap().extend_from_slice(d)), Callbacks { on_data: Arc::new(move |d| s2.lock().extend_from_slice(d)), on_error: Arc::new(|_| {}), on_close: Arc::new(move || *c2.lock() += 1) };
on_error: Arc::new(|_| {}),
on_close: Arc::new(move || *c2.lock().unwrap() += 1),
};
let sh = LocalShell::new("/bin/sh", Some("/tmp".into()), 80, 24, cb); let sh = LocalShell::new("/bin/sh", Some("/tmp".into()), 80, 24, cb);
sh.start().unwrap(); sh.start().unwrap();
sh.write(b"cd / && echo local-$((2*21))\n").unwrap(); sh.write(b"cd / && echo local-$((2*21))\n").unwrap();
let t = Instant::now(); let t = Instant::now();
while !String::from_utf8_lossy(&seen.lock().unwrap()).contains("local-42") { while !String::from_utf8_lossy(&seen.lock()).contains("local-42") {
assert!(t.elapsed() < Duration::from_secs(5)); assert!(t.elapsed() < Duration::from_secs(5));
std::thread::sleep(Duration::from_millis(20)); std::thread::sleep(Duration::from_millis(20));
} }
sh.resize(100, 30).unwrap(); sh.resize(100, 30).unwrap();
sh.write(b"exit\n").unwrap(); sh.write(b"exit\n").unwrap();
while *closed.lock().unwrap() == 0 { while *closed.lock() == 0 {
assert!(t.elapsed() < Duration::from_secs(5)); assert!(t.elapsed() < Duration::from_secs(5));
std::thread::sleep(Duration::from_millis(20)); std::thread::sleep(Duration::from_millis(20));
} }
+6 -6
View File
@@ -69,20 +69,20 @@ pub(crate) fn dial(host: &str, port: i64, timeout_secs: i64) -> (String, std::io
/// a lock, a reader thread that feeds `on_read`, and Go's error/close semantics (a read error or EOF /// a lock, a reader thread that feeds `on_read`, and Go's error/close semantics (a read error or EOF
/// reports `read error: …` then closes; `close` is idempotent and fires `on_close` once). /// reports `read error: …` then closes; `close` is idempotent and fires `on_close` once).
pub(crate) struct TcpConn { pub(crate) struct TcpConn {
stream: std::sync::Mutex<Option<std::net::TcpStream>>, stream: parking_lot::Mutex<Option<std::net::TcpStream>>,
on_error: ErrorFn, on_error: ErrorFn,
on_close: CloseFn, on_close: CloseFn,
} }
impl TcpConn { impl TcpConn {
pub(crate) fn new(on_error: ErrorFn, on_close: CloseFn) -> TcpConn { pub(crate) fn new(on_error: ErrorFn, on_close: CloseFn) -> TcpConn {
TcpConn { stream: std::sync::Mutex::new(None), on_error, on_close } TcpConn { stream: parking_lot::Mutex::new(None), on_error, on_close }
} }
pub(crate) fn connect(self: &Arc<Self>, stream: std::net::TcpStream, mut on_read: impl FnMut(&[u8]) + Send + 'static) -> crate::Result<()> { pub(crate) fn connect(self: &Arc<Self>, stream: std::net::TcpStream, mut on_read: impl FnMut(&[u8]) + Send + 'static) -> crate::Result<()> {
let mut reader = stream.try_clone()?; let mut reader = stream.try_clone()?;
{ {
let mut guard = self.stream.lock().unwrap(); let mut guard = self.stream.lock();
if guard.is_some() { if guard.is_some() {
return Err("already connected".into()); return Err("already connected".into());
} }
@@ -115,18 +115,18 @@ impl TcpConn {
pub(crate) fn write(&self, data: &[u8]) -> crate::Result<()> { pub(crate) fn write(&self, data: &[u8]) -> crate::Result<()> {
use std::io::Write; use std::io::Write;
let guard = self.stream.lock().unwrap(); let guard = self.stream.lock();
let mut s = guard.as_ref().ok_or("not connected")?; let mut s = guard.as_ref().ok_or("not connected")?;
s.write_all(data)?; s.write_all(data)?;
Ok(()) Ok(())
} }
pub(crate) fn is_connected(&self) -> bool { pub(crate) fn is_connected(&self) -> bool {
self.stream.lock().unwrap().is_some() self.stream.lock().is_some()
} }
pub(crate) fn close(&self) { pub(crate) fn close(&self) {
let taken = self.stream.lock().unwrap().take(); let taken = self.stream.lock().take();
if let Some(s) = taken { if let Some(s) = taken {
let _ = s.shutdown(std::net::Shutdown::Both); let _ = s.shutdown(std::net::Shutdown::Both);
(self.on_close)(); (self.on_close)();
+5 -4
View File
@@ -2,8 +2,9 @@
//! of sent ("send") and received ("recv") frames, JSON capture, and the hex/escape encoders the //! of sent ("send") and received ("recv") frames, JSON capture, and the hex/escape encoders the
//! RAW view reuses. //! RAW view reuses.
use parking_lot::Mutex;
use std::collections::VecDeque; use std::collections::VecDeque;
use std::sync::{Arc, Mutex}; use std::sync::Arc;
use base64::Engine; use base64::Engine;
use serde::Serialize; use serde::Serialize;
@@ -115,11 +116,11 @@ impl RawClient {
} }
pub fn history(&self) -> Vec<HistoryEntry> { pub fn history(&self) -> Vec<HistoryEntry> {
self.inner.history.lock().unwrap().entries.iter().cloned().collect() self.inner.history.lock().entries.iter().cloned().collect()
} }
pub fn clear_history(&self) { pub fn clear_history(&self) {
*self.inner.history.lock().unwrap() = History::default(); *self.inner.history.lock() = History::default();
} }
/// Writes the history to `path` as indented JSON (mode 0600 on Unix), as Go did. /// Writes the history to `path` as indented JSON (mode 0600 on Unix), as Go did.
@@ -146,7 +147,7 @@ impl Inner {
if entry.data.len() > MAX_HISTORY_BYTES { if entry.data.len() > MAX_HISTORY_BYTES {
entry.data.drain(..entry.data.len() - MAX_HISTORY_BYTES); entry.data.drain(..entry.data.len() - MAX_HISTORY_BYTES);
} }
let mut h = self.history.lock().unwrap(); let mut h = self.history.lock();
h.bytes += entry.data.len(); h.bytes += entry.data.len();
h.entries.push_back(entry); h.entries.push_back(entry);
while h.entries.len() > MAX_HISTORY_ENTRIES || h.bytes > MAX_HISTORY_BYTES { while h.entries.len() > MAX_HISTORY_ENTRIES || h.bytes > MAX_HISTORY_BYTES {
+7 -6
View File
@@ -2,8 +2,9 @@
//! `Profile::host`, line settings come from `Profile::serial`. Same conventions as the other //! `Profile::host`, line settings come from `Profile::serial`. Same conventions as the other
//! clients: blocking `connect`, an own reader thread, `on_close` exactly once (unplug included). //! clients: blocking `connect`, an own reader thread, `on_close` exactly once (unplug included).
use parking_lot::Mutex;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use serialport::{DataBits, FlowControl, Parity, SerialPort, StopBits}; use serialport::{DataBits, FlowControl, Parity, SerialPort, StopBits};
@@ -45,7 +46,7 @@ impl SerialClient {
/// Opens and configures the port, applies DTR/RTS, and starts the reader thread. /// Opens and configures the port, applies DTR/RTS, and starts the reader thread.
pub fn connect(&self) -> crate::Result<()> { pub fn connect(&self) -> crate::Result<()> {
let inner = &self.inner; let inner = &self.inner;
if inner.port.lock().unwrap().is_some() { if inner.port.lock().is_some() {
return Err("already connected".into()); return Err("already connected".into());
} }
if inner.path.is_empty() { if inner.path.is_empty() {
@@ -82,7 +83,7 @@ impl SerialClient {
reader.set_timeout(READ_POLL)?; reader.set_timeout(READ_POLL)?;
let stop = Arc::new(AtomicBool::new(false)); let stop = Arc::new(AtomicBool::new(false));
{ {
let mut guard = inner.port.lock().unwrap(); let mut guard = inner.port.lock();
if guard.is_some() { if guard.is_some() {
return Err("already connected".into()); return Err("already connected".into());
} }
@@ -128,11 +129,11 @@ impl SerialClient {
} }
pub fn is_connected(&self) -> bool { pub fn is_connected(&self) -> bool {
self.inner.port.lock().unwrap().is_some() self.inner.port.lock().is_some()
} }
fn with_port(&self, f: impl FnOnce(&mut Box<dyn SerialPort>) -> crate::Result<()>) -> crate::Result<()> { fn with_port(&self, f: impl FnOnce(&mut Box<dyn SerialPort>) -> crate::Result<()>) -> crate::Result<()> {
let mut guard = self.inner.port.lock().unwrap(); let mut guard = self.inner.port.lock();
let (port, _) = guard.as_mut().ok_or("not connected")?; let (port, _) = guard.as_mut().ok_or("not connected")?;
f(port) f(port)
} }
@@ -165,7 +166,7 @@ impl Inner {
} }
fn close(&self) { fn close(&self) {
let taken = self.port.lock().unwrap().take(); let taken = self.port.lock().take();
if let Some((port, stop)) = taken { if let Some((port, stop)) = taken {
stop.store(true, Ordering::SeqCst); stop.store(true, Ordering::SeqCst);
drop(port); drop(port);
+27 -26
View File
@@ -6,11 +6,12 @@ use super::{Callbacks, rt};
use crate::Result; use crate::Result;
use crate::models::{ForwardDirection, JumpHost, PortForward, Profile, SshConfig}; use crate::models::{ForwardDirection, JumpHost, PortForward, Profile, SshConfig};
use base64::Engine; use base64::Engine;
use parking_lot::Mutex;
use russh::client::{self, Handle, Msg}; use russh::client::{self, Handle, Msg};
use russh::keys::{HashAlg, PrivateKeyWithHashAlg, PublicKey}; use russh::keys::{HashAlg, PrivateKeyWithHashAlg, PublicKey};
use russh::{Channel, ChannelMsg, Pty}; use russh::{Channel, ChannelMsg, Pty};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::sync::mpsc; use tokio::sync::mpsc;
@@ -75,7 +76,7 @@ impl client::Handler for Handler {
match res { match res {
Ok(()) => Ok(true), Ok(()) => Ok(true),
Err(why) => { Err(why) => {
*self.shared.rejection.lock().unwrap() = Some(why); *self.shared.rejection.lock() = Some(why);
Ok(false) Ok(false)
} }
} }
@@ -91,7 +92,7 @@ impl client::Handler for Handler {
reply: client::ChannelOpenHandle, reply: client::ChannelOpenHandle,
_session: &mut client::Session, _session: &mut client::Session,
) -> std::result::Result<(), Self::Error> { ) -> std::result::Result<(), Self::Error> {
let dest = self.shared.remote_targets.lock().unwrap().get(&(connected_address.to_string(), connected_port)).cloned(); let dest = self.shared.remote_targets.lock().get(&(connected_address.to_string(), connected_port)).cloned();
let Some((host, port)) = dest else { let Some((host, port)) = dest else {
reply.reject(russh::ChannelOpenFailure::AdministrativelyProhibited).await; reply.reject(russh::ChannelOpenFailure::AdministrativelyProhibited).await;
return Ok(()); return Ok(());
@@ -485,7 +486,7 @@ impl SshClient {
match tokio::time::timeout(timeout, client::connect_stream(config(&self.sc()), stream, handler)).await { match tokio::time::timeout(timeout, client::connect_stream(config(&self.sc()), stream, handler)).await {
Err(_) => Err(format!("SSH handshake with {host}:{port} timed out").into()), Err(_) => Err(format!("SSH handshake with {host}:{port} timed out").into()),
Ok(Ok(h)) => Ok(h), Ok(Ok(h)) => Ok(h),
Ok(Err(e)) => match self.shared.rejection.lock().unwrap().take() { Ok(Err(e)) => match self.shared.rejection.lock().take() {
Some(why) => Err(why.into()), Some(why) => Err(why.into()),
None => Err(format!("SSH handshake failed: {e}").into()), None => Err(format!("SSH handshake failed: {e}").into()),
}, },
@@ -518,15 +519,15 @@ impl SshClient {
.channel_open_direct_tcpip(p.host.clone(), port as u32, "127.0.0.1", 0) .channel_open_direct_tcpip(p.host.clone(), port as u32, "127.0.0.1", 0)
.await .await
.map_err(|e| format!("failed to dial target {}:{port} through jump hosts: {e}", p.host))?; .map_err(|e| format!("failed to dial target {}:{port} through jump hosts: {e}", p.host))?;
self.hops.lock().unwrap().extend(h); self.hops.lock().extend(h);
self.hops.lock().unwrap().push(prev); self.hops.lock().push(prev);
self.handshake(ch.into_stream(), &p.host, port, &check).await? self.handshake(ch.into_stream(), &p.host, port, &check).await?
}; };
authenticate(&mut handle, Auth { user: &p.username, methods, agent: sc.agent_auth, key, password, totp_secret: sc.totp_secret.clone() }).await?; authenticate(&mut handle, Auth { user: &p.username, methods, agent: sc.agent_auth, key, password, totp_secret: sc.totp_secret.clone() }).await?;
let handle = Arc::new(handle); let handle = Arc::new(handle);
if !shell { if !shell {
*self.handle.lock().unwrap() = Some(handle); *self.handle.lock() = Some(handle);
return Ok(()); return Ok(());
} }
@@ -551,8 +552,8 @@ impl SshClient {
let (mut read, write) = ch.split(); let (mut read, write) = ch.split();
let (tx, mut rx) = mpsc::unbounded_channel::<Cmd>(); let (tx, mut rx) = mpsc::unbounded_channel::<Cmd>();
*self.tx.lock().unwrap() = Some(tx); *self.tx.lock() = Some(tx);
*self.handle.lock().unwrap() = Some(handle); *self.handle.lock() = Some(handle);
tokio::spawn(async move { tokio::spawn(async move {
while let Some(cmd) = rx.recv().await { while let Some(cmd) = rx.recv().await {
let r = match cmd { let r = match cmd {
@@ -633,16 +634,16 @@ impl SshClient {
} }
fn handle(&self) -> Result<Arc<Handle<Handler>>> { fn handle(&self) -> Result<Arc<Handle<Handler>>> {
self.handle.lock().unwrap().clone().ok_or_else(|| "not connected".into()) self.handle.lock().clone().ok_or_else(|| "not connected".into())
} }
pub fn write(&self, data: &[u8]) -> Result<()> { pub fn write(&self, data: &[u8]) -> Result<()> {
let tx = self.tx.lock().unwrap(); let tx = self.tx.lock();
tx.as_ref().ok_or("not connected")?.send(Cmd::Data(data.to_vec())).map_err(|_| "not connected".into()) tx.as_ref().ok_or("not connected")?.send(Cmd::Data(data.to_vec())).map_err(|_| "not connected".into())
} }
pub fn resize(&self, cols: u32, rows: u32) -> Result<()> { pub fn resize(&self, cols: u32, rows: u32) -> Result<()> {
let tx = self.tx.lock().unwrap(); let tx = self.tx.lock();
tx.as_ref().ok_or("not connected")?.send(Cmd::Resize(cols, rows)).map_err(|_| "not connected".into()) tx.as_ref().ok_or("not connected")?.send(Cmd::Resize(cols, rows)).map_err(|_| "not connected".into())
} }
@@ -679,7 +680,7 @@ impl SshClient {
} }
pub fn is_connected(&self) -> bool { pub fn is_connected(&self) -> bool {
self.handle.lock().unwrap().as_ref().is_some_and(|h| !h.is_closed()) self.handle.lock().as_ref().is_some_and(|h| !h.is_closed())
} }
/// Idempotent; fires on_close once. /// Idempotent; fires on_close once.
@@ -688,9 +689,9 @@ impl SshClient {
return; return;
} }
self.stop_all_forwards(); self.stop_all_forwards();
*self.tx.lock().unwrap() = None; *self.tx.lock() = None;
let h = self.handle.lock().unwrap().take(); let h = self.handle.lock().take();
let hops = std::mem::take(&mut *self.hops.lock().unwrap()); let hops = std::mem::take(&mut *self.hops.lock());
rt().spawn(async move { rt().spawn(async move {
if let Some(h) = h { if let Some(h) = h {
let _ = h.disconnect(russh::Disconnect::ByApplication, "", "en").await; let _ = h.disconnect(russh::Disconnect::ByApplication, "", "en").await;
@@ -705,7 +706,7 @@ impl SshClient {
// --- port forwarding ------------------------------------------------------------------- // --- port forwarding -------------------------------------------------------------------
pub fn start_forward(&self, rule: PortForward) -> Result<()> { pub fn start_forward(&self, rule: PortForward) -> Result<()> {
if self.forwards.lock().unwrap().contains_key(&rule.id) { if self.forwards.lock().contains_key(&rule.id) {
return Err(format!("forward {:?} is already active", rule.id).into()); return Err(format!("forward {:?} is already active", rule.id).into());
} }
let h = self.handle()?; let h = self.handle()?;
@@ -727,12 +728,12 @@ impl SshClient {
let _ = tokio::io::copy_bidirectional(&mut sock, &mut remote).await; let _ = tokio::io::copy_bidirectional(&mut sock, &mut remote).await;
} }
}); });
let mut handles = t2.lock().unwrap(); let mut handles = t2.lock();
handles.retain(|h| !h.is_finished()); handles.retain(|h| !h.is_finished());
handles.push(conn.abort_handle()); handles.push(conn.abort_handle());
} }
}); });
tasks.lock().unwrap().push(task.abort_handle()); tasks.lock().push(task.abort_handle());
} }
ForwardDirection::Remote => { ForwardDirection::Remote => {
let bind = if rule.bind_host.is_empty() { "0.0.0.0".to_string() } else { rule.bind_host.clone() }; let bind = if rule.bind_host.is_empty() { "0.0.0.0".to_string() } else { rule.bind_host.clone() };
@@ -740,26 +741,26 @@ impl SshClient {
.block_on(h.tcpip_forward(bind.clone(), rule.bind_port as u32)) .block_on(h.tcpip_forward(bind.clone(), rule.bind_port as u32))
.map_err(|e| format!("remote forward: server listen on {bind}:{} failed: {e}", rule.bind_port))?; .map_err(|e| format!("remote forward: server listen on {bind}:{} failed: {e}", rule.bind_port))?;
let port = if rule.bind_port == 0 { port } else { rule.bind_port as u32 }; let port = if rule.bind_port == 0 { port } else { rule.bind_port as u32 };
self.shared.remote_targets.lock().unwrap().insert((bind, port), (rule.dest_host.clone(), rule.dest_port as u16)); self.shared.remote_targets.lock().insert((bind, port), (rule.dest_host.clone(), rule.dest_port as u16));
} }
} }
self.forwards.lock().unwrap().insert(rule.id.clone(), Forward { rule, tasks }); self.forwards.lock().insert(rule.id.clone(), Forward { rule, tasks });
Ok(()) Ok(())
} }
pub fn stop_forward(&self, id: &str) -> Result<()> { pub fn stop_forward(&self, id: &str) -> Result<()> {
let f = self.forwards.lock().unwrap().remove(id).ok_or_else(|| format!("forward {id:?} not found"))?; let f = self.forwards.lock().remove(id).ok_or_else(|| format!("forward {id:?} not found"))?;
self.shutdown_forward(f); self.shutdown_forward(f);
Ok(()) Ok(())
} }
fn shutdown_forward(&self, f: Forward) { fn shutdown_forward(&self, f: Forward) {
for t in f.tasks.lock().unwrap().drain(..) { for t in f.tasks.lock().drain(..) {
t.abort(); t.abort();
} }
if f.rule.direction == ForwardDirection::Remote { if f.rule.direction == ForwardDirection::Remote {
let bind = if f.rule.bind_host.is_empty() { "0.0.0.0".to_string() } else { f.rule.bind_host.clone() }; let bind = if f.rule.bind_host.is_empty() { "0.0.0.0".to_string() } else { f.rule.bind_host.clone() };
self.shared.remote_targets.lock().unwrap().retain(|(a, p), _| !(a == &bind && *p == f.rule.bind_port as u32)); self.shared.remote_targets.lock().retain(|(a, p), _| !(a == &bind && *p == f.rule.bind_port as u32));
if let Ok(h) = self.handle() { if let Ok(h) = self.handle() {
let port = f.rule.bind_port as u32; let port = f.rule.bind_port as u32;
rt().spawn(async move { rt().spawn(async move {
@@ -770,14 +771,14 @@ impl SshClient {
} }
fn stop_all_forwards(&self) { fn stop_all_forwards(&self) {
let all: Vec<Forward> = self.forwards.lock().unwrap().drain().map(|(_, f)| f).collect(); let all: Vec<Forward> = self.forwards.lock().drain().map(|(_, f)| f).collect();
for f in all { for f in all {
self.shutdown_forward(f); self.shutdown_forward(f);
} }
} }
pub fn active_forwards(&self) -> Vec<PortForward> { pub fn active_forwards(&self) -> Vec<PortForward> {
self.forwards.lock().unwrap().values().map(|f| f.rule.clone()).collect() self.forwards.lock().values().map(|f| f.rule.clone()).collect()
} }
// --- SFTP ------------------------------------------------------------------------------- // --- SFTP -------------------------------------------------------------------------------
+9 -8
View File
@@ -2,7 +2,8 @@
//! TTYPE, NAWS; everything else refused), IAC escaping, line-ending conversion, ISO 8859-1 output //! TTYPE, NAWS; everything else refused), IAC escaping, line-ending conversion, ISO 8859-1 output
//! encoding and BRK. Go read but never acted on `lineMode`, and neither do we. //! encoding and BRK. Go read but never acted on `lineMode`, and neither do we.
use std::sync::{Arc, Mutex}; use parking_lot::Mutex;
use std::sync::Arc;
use super::{Callbacks, DataFn, DebugFn, TcpConn}; use super::{Callbacks, DataFn, DebugFn, TcpConn};
use crate::models::{LineEnding, Profile, TelnetConfig}; use crate::models::{LineEnding, Profile, TelnetConfig};
@@ -96,7 +97,7 @@ impl TelnetClient {
return Err("not connected".into()); return Err("not connected".into());
} }
{ {
let mut st = self.inner.state.lock().unwrap(); let mut st = self.inner.state.lock();
st.cols = cols; st.cols = cols;
st.rows = rows; st.rows = rows;
} }
@@ -119,7 +120,7 @@ impl TelnetClient {
/// The negotiated local-echo state: starts at the profile's `localEcho`, cleared by the /// The negotiated local-echo state: starts at the profile's `localEcho`, cleared by the
/// server's WILL ECHO and set by WONT ECHO. Like Go, the client itself never echoes. /// server's WILL ECHO and set by WONT ECHO. Like Go, the client itself never echoes.
pub fn local_echo(&self) -> bool { pub fn local_echo(&self) -> bool {
self.inner.state.lock().unwrap().local_echo self.inner.state.lock().local_echo
} }
} }
@@ -196,7 +197,7 @@ impl Inner {
OPT_NAWS => { OPT_NAWS => {
self.negotiate(WILL, opt); self.negotiate(WILL, opt);
let (cols, rows) = { let (cols, rows) = {
let st = self.state.lock().unwrap(); let st = self.state.lock();
(st.cols, st.rows) (st.cols, st.rows)
}; };
let _ = self.send_naws(cols, rows); let _ = self.send_naws(cols, rows);
@@ -215,7 +216,7 @@ impl Inner {
match opt { match opt {
OPT_ECHO => { OPT_ECHO => {
self.negotiate(DO, opt); self.negotiate(DO, opt);
self.state.lock().unwrap().local_echo = false; self.state.lock().local_echo = false;
} }
OPT_SGA => self.negotiate(DO, opt), OPT_SGA => self.negotiate(DO, opt),
_ => self.negotiate(DONT, opt), _ => self.negotiate(DONT, opt),
@@ -225,7 +226,7 @@ impl Inner {
fn handle_wont(&self, opt: u8) { fn handle_wont(&self, opt: u8) {
self.debug(format!("received WONT {opt}")); self.debug(format!("received WONT {opt}"));
if opt == OPT_ECHO { if opt == OPT_ECHO {
self.state.lock().unwrap().local_echo = true; self.state.lock().local_echo = true;
} }
self.negotiate(DONT, opt); self.negotiate(DONT, opt);
} }
@@ -362,7 +363,7 @@ mod tests {
on_error: Arc::new(move |e| etx.send(e).unwrap()), on_error: Arc::new(move |e| etx.send(e).unwrap()),
on_close: Arc::new(move || ctx.send(()).unwrap()), on_close: Arc::new(move || ctx.send(()).unwrap()),
}; };
let client = TelnetClient::new(&profile, cb, Some(Arc::new(move |m| dbg.lock().unwrap().push(m)))); let client = TelnetClient::new(&profile, cb, Some(Arc::new(move |m| dbg.lock().push(m))));
client.connect().unwrap(); client.connect().unwrap();
let (mut srv, _) = listener.accept().unwrap(); let (mut srv, _) = listener.accept().unwrap();
srv.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); srv.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
@@ -399,7 +400,7 @@ mod tests {
client.write(b"ls\n\xff").unwrap(); client.write(b"ls\n\xff").unwrap();
assert_eq!(read_n(&mut srv, 6), b"ls\r\n\xff\xff"); assert_eq!(read_n(&mut srv, 6), b"ls\r\n\xff\xff");
let log = debug.lock().unwrap().clone(); let log = debug.lock().clone();
for m in ["received DO 24", "sending WILL 31", "sending NAWS: 80x24", "sending WONT 99", "sending terminal type: xterm-256color"] { for m in ["received DO 24", "sending WILL 31", "sending NAWS: 80x24", "sending WONT 99", "sending terminal type: xterm-256color"] {
assert!(log.iter().any(|l| l == m), "missing debug {m:?} in {log:?}"); assert!(log.iter().any(|l| l == m), "missing debug {m:?} in {log:?}");
} }
+11 -10
View File
@@ -6,9 +6,10 @@
//! the shared `Arc<Mutex<Framebuffer>>` the UI reads, so the lock is never held across network reads. //! the shared `Arc<Mutex<Framebuffer>>` the UI reads, so the lock is never held across network reads.
//! Outgoing input is queued and written whenever a read polls out, so the transport is never split. //! Outgoing input is queued and written whenever a read polls out, so the transport is never split.
use parking_lot::{Mutex, MutexGuard};
use std::io::{BufReader, ErrorKind, Read, Write}; use std::io::{BufReader, ErrorKind, Read, Write};
use std::sync::Arc;
use std::sync::mpsc::{self, Receiver, Sender, TryRecvError}; use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::Duration; use std::time::Duration;
use flate2::{Decompress, FlushDecompress}; use flate2::{Decompress, FlushDecompress};
@@ -152,7 +153,7 @@ impl Client {
} }
fn st(&self) -> MutexGuard<'_, State> { fn st(&self) -> MutexGuard<'_, State> {
self.state.lock().unwrap_or_else(|e| e.into_inner()) self.state.lock()
} }
/// Connects and blocks until the handshake completes (ServerInit received), fails, or times out. /// Connects and blocks until the handshake completes (ServerInit received), fails, or times out.
@@ -160,7 +161,7 @@ impl Client {
let (ready_tx, ready_rx) = mpsc::channel(); let (ready_tx, ready_rx) = mpsc::channel();
let (out_tx, out_rx) = mpsc::channel(); let (out_tx, out_rx) = mpsc::channel();
self.st().ready = Some(ready_tx); self.st().ready = Some(ready_tx);
*self.out.lock().unwrap_or_else(|e| e.into_inner()) = Some(out_tx); *self.out.lock() = Some(out_tx);
let client = self.clone(); let client = self.clone();
std::thread::spawn(move || client.io_thread(out_rx)); std::thread::spawn(move || client.io_thread(out_rx));
@@ -186,7 +187,7 @@ impl Client {
st.closing = true; st.closing = true;
} }
// Dropping the sender makes the I/O thread's next poll end the connection. // Dropping the sender makes the I/O thread's next poll end the connection.
*self.out.lock().unwrap_or_else(|e| e.into_inner()) = None; *self.out.lock() = None;
} }
pub fn framebuffer(&self) -> Arc<Mutex<Framebuffer>> { pub fn framebuffer(&self) -> Arc<Mutex<Framebuffer>> {
@@ -233,20 +234,20 @@ impl Client {
/// Asks for a non-incremental update of the whole screen. /// Asks for a non-incremental update of the whole screen.
pub fn request_full_update(&self) -> crate::Result<()> { pub fn request_full_update(&self) -> crate::Result<()> {
let (w, h) = { let (w, h) = {
let fb = self.fb.lock().unwrap_or_else(|e| e.into_inner()); let fb = self.fb.lock();
(fb.width, fb.height) (fb.width, fb.height)
}; };
self.queue(update_request(false, w, h)) self.queue(update_request(false, w, h))
} }
fn queue(&self, msg: Vec<u8>) -> crate::Result<()> { fn queue(&self, msg: Vec<u8>) -> crate::Result<()> {
let guard = self.out.lock().unwrap_or_else(|e| e.into_inner()); let guard = self.out.lock();
if guard.as_ref().is_some_and(|tx| tx.send(msg).is_ok()) { Ok(()) } else { Err("not connected".into()) } if guard.as_ref().is_some_and(|tx| tx.send(msg).is_ok()) { Ok(()) } else { Err("not connected".into()) }
} }
fn io_thread(self: Arc<Self>, out_rx: Receiver<Vec<u8>>) { fn io_thread(self: Arc<Self>, out_rx: Receiver<Vec<u8>>) {
let result = self.open_transport().and_then(|io| self.run(io, out_rx)); let result = self.open_transport().and_then(|io| self.run(io, out_rx));
*self.out.lock().unwrap_or_else(|e| e.into_inner()) = None; *self.out.lock() = None;
let (was_closing, was_connected, ready) = { let (was_closing, was_connected, ready) = {
let mut st = self.st(); let mut st = self.st();
let r = (st.closing, st.connected, st.ready.take()); let r = (st.closing, st.connected, st.ready.take());
@@ -283,7 +284,7 @@ impl Client {
let mut r = BufReader::with_capacity(1 << 16, Conn { io, out_rx }); let mut r = BufReader::with_capacity(1 << 16, Conn { io, out_rx });
let (w, h) = self.handshake(&mut r)?; let (w, h) = self.handshake(&mut r)?;
let mut local = Framebuffer::new_checked(w, h)?; let mut local = Framebuffer::new_checked(w, h)?;
*self.fb.lock().unwrap_or_else(|e| e.into_inner()) = local.clone(); *self.fb.lock() = local.clone();
(self.cfg.on_resize)(w, h); (self.cfg.on_resize)(w, h);
{ {
let mut st = self.st(); let mut st = self.st();
@@ -302,7 +303,7 @@ impl Client {
0 => { 0 => {
let (y0, y1, resized) = read_update(&mut r, &mut local, &mut dec)?; let (y0, y1, resized) = read_update(&mut r, &mut local, &mut dec)?;
{ {
let mut fb = self.fb.lock().unwrap_or_else(|e| e.into_inner()); let mut fb = self.fb.lock();
if resized { if resized {
*fb = local.clone(); *fb = local.clone();
} else if y0 < y1 { } else if y0 < y1 {
@@ -983,7 +984,7 @@ mod tests {
frame_rx.recv_timeout(Duration::from_secs(5)).unwrap(); frame_rx.recv_timeout(Duration::from_secs(5)).unwrap();
{ {
let fb = client.framebuffer(); let fb = client.framebuffer();
let fb = fb.lock().unwrap(); let fb = fb.lock();
assert_eq!((fb.width, fb.height), (4, 4)); assert_eq!((fb.width, fb.height), (4, 4));
assert!(fb.dirty); assert!(fb.dirty);
let px = |x: usize, y: usize| &fb.rgba[(y * 4 + x) * 4..(y * 4 + x) * 4 + 4]; let px = |x: usize, y: usize| &fb.rgba[(y * 4 + x) * 4..(y * 4 + x) * 4 + 4];
+4 -4
View File
@@ -3,12 +3,12 @@
use crate::Result; use crate::Result;
use crate::models::Time; use crate::models::Time;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::fs::File; use std::fs::File;
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::Instant; use std::time::Instant;
pub const EVENT_OUTPUT: &str = "o"; pub const EVENT_OUTPUT: &str = "o";
@@ -156,7 +156,7 @@ impl Recorder {
} }
fn write_event(&self, kind: &str, data: &[u8]) { fn write_event(&self, kind: &str, data: &[u8]) {
let mut g = self.inner.lock().unwrap(); let mut g = self.inner.lock();
if g.closed || g.file.is_none() { if g.closed || g.file.is_none() {
return; return;
} }
@@ -177,7 +177,7 @@ impl Recorder {
/// Flushes and closes the file. Idempotent. /// Flushes and closes the file. Idempotent.
pub fn close(&self) -> Result<()> { pub fn close(&self) -> Result<()> {
let mut g = self.inner.lock().unwrap(); let mut g = self.inner.lock();
if g.closed { if g.closed {
return Ok(()); return Ok(());
} }
@@ -198,7 +198,7 @@ impl Recorder {
} }
pub fn event_count(&self) -> usize { pub fn event_count(&self) -> usize {
self.inner.lock().unwrap().event_count self.inner.lock().event_count
} }
} }
+9 -9
View File
@@ -6,11 +6,11 @@ use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce}; use aes_gcm::{Aes256Gcm, Nonce};
use base64::Engine; use base64::Engine;
use base64::engine::general_purpose::STANDARD as B64; use base64::engine::general_purpose::STANDARD as B64;
use parking_lot::Mutex;
use rand::RngCore; use rand::RngCore;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Mutex;
use zeroize::Zeroizing; use zeroize::Zeroizing;
const VAULT_FILE: &str = "vault.json"; const VAULT_FILE: &str = "vault.json";
@@ -93,7 +93,7 @@ impl Vault {
} }
pub fn is_unlocked(&self) -> bool { pub fn is_unlocked(&self) -> bool {
self.state.lock().unwrap().is_some() self.state.lock().is_some()
} }
pub fn initialise(&self, password: &str) -> Result<()> { pub fn initialise(&self, password: &str) -> Result<()> {
@@ -104,7 +104,7 @@ impl Vault {
let u = Unlocked { key: derive_key(password, &salt)?, vault: VaultFile { version: 1, salt: B64.encode(salt), secrets: BTreeMap::new() } }; let u = Unlocked { key: derive_key(password, &salt)?, vault: VaultFile { version: 1, salt: B64.encode(salt), secrets: BTreeMap::new() } };
std::fs::create_dir_all(&self.dir)?; std::fs::create_dir_all(&self.dir)?;
self.save(&u)?; self.save(&u)?;
*self.state.lock().unwrap() = Some(u); *self.state.lock() = Some(u);
Ok(()) Ok(())
} }
@@ -121,12 +121,12 @@ impl Vault {
let data = B64.decode(v).unwrap_or_default(); let data = B64.decode(v).unwrap_or_default();
open(&u.key, &data).map_err(|_| "secrets: incorrect master password or corrupt vault")?; open(&u.key, &data).map_err(|_| "secrets: incorrect master password or corrupt vault")?;
} }
*self.state.lock().unwrap() = Some(u); *self.state.lock() = Some(u);
Ok(()) Ok(())
} }
pub fn lock(&self) { pub fn lock(&self) {
*self.state.lock().unwrap() = None; *self.state.lock() = None;
} }
fn save(&self, u: &Unlocked) -> Result<()> { fn save(&self, u: &Unlocked) -> Result<()> {
@@ -135,7 +135,7 @@ impl Vault {
} }
pub fn get(&self, key: &str) -> Result<String> { pub fn get(&self, key: &str) -> Result<String> {
let st = self.state.lock().unwrap(); let st = self.state.lock();
let u = st.as_ref().ok_or("secrets: vault is locked")?; let u = st.as_ref().ok_or("secrets: vault is locked")?;
let enc = u.vault.secrets.get(key).ok_or_else(|| format!("secrets: key {key:?} not found"))?; let enc = u.vault.secrets.get(key).ok_or_else(|| format!("secrets: key {key:?} not found"))?;
let data = B64.decode(enc).map_err(|e| format!("failed to decode secret: {e}"))?; let data = B64.decode(enc).map_err(|e| format!("failed to decode secret: {e}"))?;
@@ -143,7 +143,7 @@ impl Vault {
} }
pub fn set(&self, key: &str, value: &str) -> Result<()> { pub fn set(&self, key: &str, value: &str) -> Result<()> {
let mut st = self.state.lock().unwrap(); let mut st = self.state.lock();
let u = st.as_mut().ok_or("secrets: vault is locked")?; let u = st.as_mut().ok_or("secrets: vault is locked")?;
let enc = B64.encode(seal(&u.key, value.as_bytes())); let enc = B64.encode(seal(&u.key, value.as_bytes()));
u.vault.secrets.insert(key.to_string(), enc); u.vault.secrets.insert(key.to_string(), enc);
@@ -151,14 +151,14 @@ impl Vault {
} }
pub fn delete_prefix(&self, prefix: &str) -> Result<()> { pub fn delete_prefix(&self, prefix: &str) -> Result<()> {
let mut st = self.state.lock().unwrap(); let mut st = self.state.lock();
let u = st.as_mut().ok_or("secrets: vault is locked")?; let u = st.as_mut().ok_or("secrets: vault is locked")?;
u.vault.secrets.retain(|k, _| !k.starts_with(prefix)); u.vault.secrets.retain(|k, _| !k.starts_with(prefix));
self.save(u) self.save(u)
} }
pub fn delete(&self, key: &str) -> Result<()> { pub fn delete(&self, key: &str) -> Result<()> {
let mut st = self.state.lock().unwrap(); let mut st = self.state.lock();
let u = st.as_mut().ok_or("secrets: vault is locked")?; let u = st.as_mut().ok_or("secrets: vault is locked")?;
u.vault.secrets.remove(key); u.vault.secrets.remove(key);
self.save(u) self.save(u)
+42 -41
View File
@@ -10,11 +10,12 @@ use crate::models::*;
use crate::recording::{self, Recorder}; use crate::recording::{self, Recorder};
use crate::secrets::Secrets; use crate::secrets::Secrets;
use crate::storage::Store; use crate::storage::Store;
use parking_lot::Mutex;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc; use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
/// Everything the UI hears about. Prompts carry their reply channel; dropping it cancels. /// Everything the UI hears about. Prompts carry their reply channel; dropping it cancels.
@@ -222,7 +223,7 @@ struct SecretState {
/// After a failed attempt, typed credentials are dropped and, when the server refused them, the /// 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. /// saved ones used are skipped next time so the user can enter a replacement.
fn attempt_failed(state: &SessionSecrets, error: &str) { fn attempt_failed(state: &SessionSecrets, error: &str) {
let mut st = state.lock().unwrap(); let mut st = state.lock();
st.pending.clear(); st.pending.clear();
if is_auth_error(error) { if is_auth_error(error) {
let used = std::mem::take(&mut st.used); let used = std::mem::take(&mut st.used);
@@ -263,11 +264,11 @@ impl Manager {
} }
pub fn set_sink(&self, sink: EventSink) { pub fn set_sink(&self, sink: EventSink) {
*self.sink.lock().unwrap() = Some(sink); *self.sink.lock() = Some(sink);
} }
fn emit(&self, e: Event) { fn emit(&self, e: Event) {
let sink = self.sink.lock().unwrap().clone(); let sink = self.sink.lock().clone();
if let Some(s) = sink { if let Some(s) = sink {
s(e); s(e);
} }
@@ -279,11 +280,11 @@ impl Manager {
/// True while `id` still names the Session object with `epoch` and it isn't closed. /// True while `id` still names the Session object with `epoch` and it isn't closed.
fn is_current(&self, id: &str, epoch: u64) -> bool { fn is_current(&self, id: &str, epoch: u64) -> bool {
self.sessions.lock().unwrap().get(id).is_some_and(|s| s.epoch == epoch && !s.closed) self.sessions.lock().get(id).is_some_and(|s| s.epoch == epoch && !s.closed)
} }
fn with<R>(&self, id: &str, f: impl FnOnce(&mut Session) -> R) -> Result<R> { fn with<R>(&self, id: &str, f: impl FnOnce(&mut Session) -> R) -> Result<R> {
let mut map = self.sessions.lock().unwrap(); let mut map = self.sessions.lock();
match map.get_mut(id) { match map.get_mut(id) {
Some(s) => Ok(f(s)), Some(s) => Ok(f(s)),
None => err(format!("sessions: session {id:?} not found")), None => err(format!("sessions: session {id:?} not found")),
@@ -312,7 +313,7 @@ impl Manager {
session_secrets, session_secrets,
}; };
let id = s.info.id.clone(); let id = s.info.id.clone();
self.sessions.lock().unwrap().insert(id, s); self.sessions.lock().insert(id, s);
epoch epoch
} }
@@ -353,7 +354,7 @@ impl Manager {
} }
fn finish_connect(&self, id: &str, epoch: u64, r: Result<Client>, on_ok: impl FnOnce(&Self)) { fn finish_connect(&self, id: &str, epoch: u64, r: Result<Client>, on_ok: impl FnOnce(&Self)) {
let mut map = self.sessions.lock().unwrap(); let mut map = self.sessions.lock();
let current = map.get_mut(id).filter(|s| s.epoch == epoch && !s.closed); let current = map.get_mut(id).filter(|s| s.epoch == epoch && !s.closed);
match (r, current) { match (r, current) {
(Ok(client), None) => { (Ok(client), None) => {
@@ -369,7 +370,7 @@ impl Manager {
self.emit_state(id, SessionState::Error, &e.to_string()); self.emit_state(id, SessionState::Error, &e.to_string());
} }
(Ok(client), Some(s)) => { (Ok(client), Some(s)) => {
let typed = std::mem::take(&mut s.session_secrets.lock().unwrap().pending); let typed = std::mem::take(&mut s.session_secrets.lock().pending);
s.client = client.clone(); s.client = client.clone();
s.info.state = SessionState::Connected; s.info.state = SessionState::Connected;
s.info.connected_at = now(); s.info.connected_at = now();
@@ -392,7 +393,7 @@ impl Manager {
fn connect_protocol(self: &Arc<Self>, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result<Client> { fn connect_protocol(self: &Arc<Self>, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result<Client> {
let _ = self.with(id, |s| { let _ = self.with(id, |s| {
let mut st = s.session_secrets.lock().unwrap(); let mut st = s.session_secrets.lock();
st.pending.clear(); st.pending.clear();
st.used.clear(); st.used.clear();
}); });
@@ -431,15 +432,15 @@ impl Manager {
/// Callbacks for terminal-style clients: output to the UI, recording and login scripts. /// Callbacks for terminal-style clients: output to the UI, recording and login scripts.
fn terminal_callbacks(self: &Arc<Self>, id: &str, epoch: u64) -> Callbacks { fn terminal_callbacks(self: &Arc<Self>, id: &str, epoch: u64) -> Callbacks {
let login = self.sessions.lock().unwrap().get(id).map(|s| s.login.clone()).unwrap_or_default(); let login = self.sessions.lock().get(id).map(|s| s.login.clone()).unwrap_or_default();
let (me, sid) = (self.clone(), id.to_string()); let (me, sid) = (self.clone(), id.to_string());
let on_data = Arc::new(move |d: &[u8]| { let on_data = Arc::new(move |d: &[u8]| {
me.emit(Event::Data { session: sid.clone(), data: d.to_vec() }); me.emit(Event::Data { session: sid.clone(), data: d.to_vec() });
let rec = me.sessions.lock().unwrap().get(&sid).and_then(|s| s.recorder.clone()); let rec = me.sessions.lock().get(&sid).and_then(|s| s.recorder.clone());
if let Some(r) = rec { if let Some(r) = rec {
r.write_output(d); r.write_output(d);
} }
let mut l = login.lock().unwrap(); let mut l = login.lock();
if l.active { if l.active {
l.buf.extend_from_slice(d); l.buf.extend_from_slice(d);
if l.buf.len() > 16384 { if l.buf.len() > 16384 {
@@ -485,10 +486,10 @@ impl Manager {
let key = format!("profile:{profile_id}:{kind}"); let key = format!("profile:{profile_id}:{kind}");
let (secrets, store) = (self.secrets.clone(), self.store.clone()); let (secrets, store) = (self.secrets.clone(), self.store.clone());
let (pid, kind) = (profile_id.to_string(), kind.to_string()); let (pid, kind) = (profile_id.to_string(), kind.to_string());
let state: SessionSecrets = self.sessions.lock().unwrap().get(session).map(|s| s.session_secrets.clone()).unwrap_or_default(); let state: SessionSecrets = self.sessions.lock().get(session).map(|s| s.session_secrets.clone()).unwrap_or_default();
Arc::new(move || { Arc::new(move || {
if mode == SecretMode::Saved { if mode == SecretMode::Saved {
let mut st = state.lock().unwrap(); let mut st = state.lock();
if let Some(v) = st.pending.get(&key) { if let Some(v) = st.pending.get(&key) {
return Ok(v.clone()); return Ok(v.clone());
} }
@@ -501,12 +502,12 @@ impl Manager {
} }
} }
if mode == SecretMode::SessionOnly if mode == SecretMode::SessionOnly
&& let Some(v) = state.lock().unwrap().session_only.get(&key).cloned() && let Some(v) = state.lock().session_only.get(&key).cloned()
{ {
return Ok(v); return Ok(v);
} }
let v = prompt(&format!("Enter {kind}:"))?; let v = prompt(&format!("Enter {kind}:"))?;
let mut st = state.lock().unwrap(); let mut st = state.lock();
match mode { match mode {
SecretMode::Saved => { SecretMode::Saved => {
st.pending.insert(key.clone(), v.clone()); st.pending.insert(key.clone(), v.clone());
@@ -693,7 +694,7 @@ impl Manager {
let retry = auth.then(|| prompt("GitHub token did not work. Please enter a valid token:").ok()).flatten().filter(|t| !t.is_empty()); 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}")) }; let Some(t) = retry else { return err(format!("GitHub authentication failed: {e}")) };
if gh.secret_mode == SecretMode::Saved { if gh.secret_mode == SecretMode::Saved {
let _ = self.with(id, |s| s.session_secrets.lock().unwrap().pending.insert(format!("profile:{}:github-token", p.id), t.clone())); let _ = self.with(id, |s| s.session_secrets.lock().pending.insert(format!("profile:{}:github-token", p.id), t.clone()));
} }
client = github::Client::new(&gh.base_url, &t); client = github::Client::new(&gh.base_url, &t);
client.validate_token().map_err(|e| format!("GitHub authentication failed: {e}"))? client.validate_token().map_err(|e| format!("GitHub authentication failed: {e}"))?
@@ -963,7 +964,7 @@ impl Manager {
/// Explicit close: the tab is gone, so no reconnect. /// Explicit close: the tab is gone, so no reconnect.
pub fn disconnect(&self, id: &str) -> Result<()> { pub fn disconnect(&self, id: &str) -> Result<()> {
let s = self.sessions.lock().unwrap().remove(id).ok_or_else(|| format!("sessions: session {id:?} not found"))?; let s = self.sessions.lock().remove(id).ok_or_else(|| format!("sessions: session {id:?} not found"))?;
self.close_session(s); self.close_session(s);
self.store.add_audit("disconnect", "", id, "Session disconnected"); self.store.add_audit("disconnect", "", id, "Session disconnected");
Ok(()) Ok(())
@@ -972,7 +973,7 @@ impl Manager {
/// Closes and re-opens a session from its profile, keeping the tab name and pin; returns the /// Closes and re-opens a session from its profile, keeping the tab name and pin; returns the
/// new session id. /// new session id.
pub fn reconnect(self: &Arc<Self>, id: &str) -> Result<String> { pub fn reconnect(self: &Arc<Self>, id: &str) -> Result<String> {
let s = self.sessions.lock().unwrap().remove(id).ok_or_else(|| format!("sessions: session {id:?} not found"))?; 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 (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(); let session_secrets = s.session_secrets.clone();
self.close_session(s); self.close_session(s);
@@ -1001,7 +1002,7 @@ impl Manager {
fn handle_closed(self: &Arc<Self>, id: &str, epoch: u64) { fn handle_closed(self: &Arc<Self>, id: &str, epoch: u64) {
let profile = { let profile = {
let mut map = self.sessions.lock().unwrap(); let mut map = self.sessions.lock();
let Some(s) = map.get_mut(id).filter(|s| s.epoch == epoch && !s.closed) else { return }; let Some(s) = map.get_mut(id).filter(|s| s.epoch == epoch && !s.closed) else { return };
s.info.state = SessionState::Disconnected; s.info.state = SessionState::Disconnected;
if !matches!(s.client, Client::Local(_)) { if !matches!(s.client, Client::Local(_)) {
@@ -1109,7 +1110,7 @@ impl Manager {
} }
pub fn close_all(&self) { pub fn close_all(&self) {
let all: Vec<Session> = self.sessions.lock().unwrap().drain().map(|(_, s)| s).collect(); let all: Vec<Session> = self.sessions.lock().drain().map(|(_, s)| s).collect();
for s in all { for s in all {
self.close_session(s); self.close_session(s);
} }
@@ -1122,7 +1123,7 @@ impl Manager {
return; return;
} }
let Ok(login) = self.with(id, |s| s.login.clone()) else { return }; let Ok(login) = self.with(id, |s| s.login.clone()) else { return };
*login.lock().unwrap() = LoginBuf { active: true, buf: vec![] }; *login.lock() = LoginBuf { active: true, buf: vec![] };
let (me, id) = (self.clone(), id.to_string()); let (me, id) = (self.clone(), id.to_string());
std::thread::spawn(move || { std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(500)); std::thread::sleep(Duration::from_millis(500));
@@ -1157,7 +1158,7 @@ impl Manager {
_ => {} _ => {}
} }
} }
*login.lock().unwrap() = LoginBuf::default(); *login.lock() = LoginBuf::default();
}); });
} }
@@ -1202,15 +1203,15 @@ impl Manager {
} }
pub fn info(&self, id: &str) -> Option<SessionInfo> { pub fn info(&self, id: &str) -> Option<SessionInfo> {
self.sessions.lock().unwrap().get(id).map(|s| s.info.clone()) self.sessions.lock().get(id).map(|s| s.info.clone())
} }
pub fn list(&self) -> Vec<SessionInfo> { pub fn list(&self) -> Vec<SessionInfo> {
self.sessions.lock().unwrap().values().map(|s| s.info.clone()).collect() self.sessions.lock().values().map(|s| s.info.clone()).collect()
} }
pub fn profile(&self, id: &str) -> Option<Profile> { pub fn profile(&self, id: &str) -> Option<Profile> {
self.sessions.lock().unwrap().get(id).and_then(|s| s.profile.clone()) self.sessions.lock().get(id).and_then(|s| s.profile.clone())
} }
pub fn rename(&self, id: &str, name: &str) -> Result<()> { pub fn rename(&self, id: &str, name: &str) -> Result<()> {
@@ -1426,7 +1427,7 @@ fn wait_for_output(login: &Mutex<LoginBuf>, pattern: &str, timeout: Duration) ->
let deadline = std::time::Instant::now() + timeout; let deadline = std::time::Instant::now() + timeout;
while std::time::Instant::now() < deadline { while std::time::Instant::now() < deadline {
{ {
let mut l = login.lock().unwrap(); let mut l = login.lock();
let text = String::from_utf8_lossy(&l.buf).into_owned(); let text = String::from_utf8_lossy(&l.buf).into_owned();
if let Some(i) = text.find(pattern) { if let Some(i) = text.find(pattern) {
let consumed = text[..i + pattern.len()].len(); let consumed = text[..i + pattern.len()].len();
@@ -1469,7 +1470,7 @@ mod tests {
let w = warnings.clone(); let w = warnings.clone();
m.set_sink(Arc::new(move |e| { m.set_sink(Arc::new(move |e| {
if let Event::Warning { message, .. } = e { if let Event::Warning { message, .. } = e {
w.lock().unwrap().push(message); w.lock().push(message);
} }
})); }));
let p = Profile { host: "127.0.0.1".into(), port: 1, ..Profile::new(Protocol::Raw) }; let p = Profile { host: "127.0.0.1".into(), port: 1, ..Profile::new(Protocol::Raw) };
@@ -1494,7 +1495,7 @@ mod tests {
let e = m.new_session(info("s0"), Some(p.clone()), String::new(), SessionSecrets::default()); let e = m.new_session(info("s0"), Some(p.clone()), String::new(), SessionSecrets::default());
resolve(&m, "s0"); resolve(&m, "s0");
m.finish_connect("s0", e, Ok(Client::None), |_| {}); m.finish_connect("s0", e, Ok(Client::None), |_| {});
assert_eq!(warnings.lock().unwrap().len(), 1); assert_eq!(warnings.lock().len(), 1);
m.secrets.vault.initialise("pw").unwrap(); m.secrets.vault.initialise("pw").unwrap();
let e = m.new_session(info("s1"), Some(p.clone()), String::new(), SessionSecrets::default()); let e = m.new_session(info("s1"), Some(p.clone()), String::new(), SessionSecrets::default());
@@ -1570,40 +1571,40 @@ mod tests {
let (a, pr) = (answers.clone(), prompts.clone()); let (a, pr) = (answers.clone(), prompts.clone());
m.set_sink(Arc::new(move |e| { m.set_sink(Arc::new(move |e| {
if let Event::HostKeyVerify { stored, reply, .. } = e { if let Event::HostKeyVerify { stored, reply, .. } = e {
pr.lock().unwrap().push(stored); pr.lock().push(stored);
let _ = reply.send(a.lock().unwrap().remove(0)); 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 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); 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. // Unknown key: accept once records nothing; accept & save records it.
let check = m.host_key_check("s", HostKeyPolicy::Ask); let check = m.host_key_check("s", HostKeyPolicy::Ask);
answers.lock().unwrap().push(HostKeyDecision::AcceptOnce); answers.lock().push(HostKeyDecision::AcceptOnce);
check(&info("A")).unwrap(); check(&info("A")).unwrap();
assert_eq!(stored(&m), None); assert_eq!(stored(&m), None);
answers.lock().unwrap().push(HostKeyDecision::AcceptAndSave); answers.lock().push(HostKeyDecision::AcceptAndSave);
check(&info("A")).unwrap(); check(&info("A")).unwrap();
assert_eq!(stored(&m).as_deref(), Some("A")); assert_eq!(stored(&m).as_deref(), Some("A"));
assert_eq!(prompts.lock().unwrap().as_slice(), [None, None]); assert_eq!(prompts.lock().as_slice(), [None, None]);
// Same key again: no prompt. // Same key again: no prompt.
check(&info("A")).unwrap(); check(&info("A")).unwrap();
assert_eq!(prompts.lock().unwrap().len(), 2); assert_eq!(prompts.lock().len(), 2);
// Changed key: reject, accept once (record untouched), accept & update (record replaced). // Changed key: reject, accept once (record untouched), accept & update (record replaced).
answers.lock().unwrap().push(HostKeyDecision::Reject); answers.lock().push(HostKeyDecision::Reject);
assert!(check(&info("B")).unwrap_err().contains("has changed")); assert!(check(&info("B")).unwrap_err().contains("has changed"));
answers.lock().unwrap().push(HostKeyDecision::AcceptOnce); answers.lock().push(HostKeyDecision::AcceptOnce);
check(&info("B")).unwrap(); check(&info("B")).unwrap();
assert_eq!(stored(&m).as_deref(), Some("A")); assert_eq!(stored(&m).as_deref(), Some("A"));
answers.lock().unwrap().push(HostKeyDecision::AcceptAndSave); answers.lock().push(HostKeyDecision::AcceptAndSave);
check(&info("B")).unwrap(); check(&info("B")).unwrap();
assert_eq!(stored(&m).as_deref(), Some("B")); assert_eq!(stored(&m).as_deref(), Some("B"));
assert_eq!(prompts.lock().unwrap()[2..], [Some("A".to_string()), Some("A".into()), Some("A".into())]); 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); assert_eq!(m.store.list_known_hosts().unwrap().len(), 1);
// Strict and accept-new never prompt on a changed key. // Strict and accept-new never prompt on a changed key.
for policy in [HostKeyPolicy::Strict, HostKeyPolicy::AcceptNew] { for policy in [HostKeyPolicy::Strict, HostKeyPolicy::AcceptNew] {
assert!(m.host_key_check("s", policy)(&info("C")).unwrap_err().contains("has changed")); assert!(m.host_key_check("s", policy)(&info("C")).unwrap_err().contains("has changed"));
} }
assert_eq!(prompts.lock().unwrap().len(), 5); assert_eq!(prompts.lock().len(), 5);
let _ = std::fs::remove_dir_all(dir); let _ = std::fs::remove_dir_all(dir);
} }
} }
+19 -24
View File
@@ -4,9 +4,9 @@
use crate::Result; use crate::Result;
use crate::models::*; use crate::models::*;
use chrono::{DateTime, SecondsFormat}; use chrono::{DateTime, SecondsFormat};
use parking_lot::Mutex;
use rusqlite::{Connection, OptionalExtension, Row, params}; use rusqlite::{Connection, OptionalExtension, Row, params};
use std::path::Path; use std::path::Path;
use std::sync::Mutex;
const MIGRATIONS: &[(i64, &str)] = &[ const MIGRATIONS: &[(i64, &str)] = &[
(1, include_str!("../migrations/001_initial.sql")), (1, include_str!("../migrations/001_initial.sql")),
@@ -121,7 +121,7 @@ impl Store {
} }
fn migrate(&self) -> Result<()> { fn migrate(&self) -> Result<()> {
let db = self.db.lock().unwrap(); let db = self.db.lock();
db.execute_batch("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)")?; db.execute_batch("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)")?;
let current: i64 = db.query_row("SELECT COALESCE(MAX(version), 0) FROM schema_version", [], |r| r.get(0))?; let current: i64 = db.query_row("SELECT COALESCE(MAX(version), 0) FROM schema_version", [], |r| r.get(0))?;
for (ver, sql) in MIGRATIONS { for (ver, sql) in MIGRATIONS {
@@ -170,7 +170,6 @@ impl Store {
let sql = format!("INSERT INTO profiles ({PROFILE_COLS}) VALUES ({})", vec!["?"; 28].join(",")); let sql = format!("INSERT INTO profiles ({PROFILE_COLS}) VALUES ({})", vec!["?"; 28].join(","));
self.db self.db
.lock() .lock()
.unwrap()
.execute(&sql, rusqlite::params_from_iter(args.iter().map(|b| b.as_ref()))) .execute(&sql, rusqlite::params_from_iter(args.iter().map(|b| b.as_ref())))
.map_err(|e| format!("storage: failed to create profile: {e}"))?; .map_err(|e| format!("storage: failed to create profile: {e}"))?;
Ok(()) Ok(())
@@ -183,7 +182,6 @@ impl Store {
let n = self let n = self
.db .db
.lock() .lock()
.unwrap()
.execute( .execute(
"UPDATE profiles SET display_name=?, description=?, protocol=?, host=?, port=?, username=?, "UPDATE profiles SET display_name=?, description=?, protocol=?, host=?, port=?, username=?,
tags=?, folder=?, environment=?, notes=?, favourite=?, last_connected=?, tags=?, folder=?, environment=?, notes=?, favourite=?, last_connected=?,
@@ -203,14 +201,13 @@ impl Store {
pub fn get_profile(&self, id: &str) -> Result<Profile> { pub fn get_profile(&self, id: &str) -> Result<Profile> {
self.db self.db
.lock() .lock()
.unwrap()
.query_row(&format!("SELECT {PROFILE_COLS} FROM profiles WHERE id = ?"), [id], scan_profile) .query_row(&format!("SELECT {PROFILE_COLS} FROM profiles WHERE id = ?"), [id], scan_profile)
.optional()? .optional()?
.ok_or_else(|| format!("storage: profile {id:?} not found").into()) .ok_or_else(|| format!("storage: profile {id:?} not found").into())
} }
pub fn delete_profile(&self, id: &str) -> Result<()> { pub fn delete_profile(&self, id: &str) -> Result<()> {
let mut db = self.db.lock().unwrap(); let mut db = self.db.lock();
let tx = db.transaction()?; let tx = db.transaction()?;
tx.execute("DELETE FROM irc_channel_state WHERE profile_id = ?", [id])?; tx.execute("DELETE FROM irc_channel_state WHERE profile_id = ?", [id])?;
if tx.execute("DELETE FROM profiles WHERE id = ?", [id])? == 0 { if tx.execute("DELETE FROM profiles WHERE id = ?", [id])? == 0 {
@@ -221,14 +218,14 @@ impl Store {
} }
pub fn list_profiles(&self) -> Result<Vec<Profile>> { pub fn list_profiles(&self) -> Result<Vec<Profile>> {
let db = self.db.lock().unwrap(); let db = self.db.lock();
let mut st = db.prepare(&format!("SELECT {PROFILE_COLS} FROM profiles ORDER BY display_name"))?; let mut st = db.prepare(&format!("SELECT {PROFILE_COLS} FROM profiles ORDER BY display_name"))?;
let rows = st.query_map([], scan_profile)?.collect::<rusqlite::Result<Vec<_>>>()?; let rows = st.query_map([], scan_profile)?.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows) Ok(rows)
} }
pub fn set_favourite(&self, id: &str, fav: bool) -> Result<()> { pub fn set_favourite(&self, id: &str, fav: bool) -> Result<()> {
let n = self.db.lock().unwrap().execute("UPDATE profiles SET favourite = ?, updated_at = ? WHERE id = ?", params![fav as i64, fmt_time(&now()), id])?; let n = self.db.lock().execute("UPDATE profiles SET favourite = ?, updated_at = ? WHERE id = ?", params![fav as i64, fmt_time(&now()), id])?;
if n == 0 { if n == 0 {
return Err(format!("storage: profile {id:?} not found").into()); return Err(format!("storage: profile {id:?} not found").into());
} }
@@ -237,7 +234,7 @@ impl Store {
pub fn update_last_connected(&self, id: &str) -> Result<()> { pub fn update_last_connected(&self, id: &str) -> Result<()> {
let now = fmt_time(&now()); let now = fmt_time(&now());
self.db.lock().unwrap().execute("UPDATE profiles SET last_connected = ?, updated_at = ? WHERE id = ?", params![now, now, id])?; self.db.lock().execute("UPDATE profiles SET last_connected = ?, updated_at = ? WHERE id = ?", params![now, now, id])?;
Ok(()) Ok(())
} }
@@ -273,7 +270,6 @@ impl Store {
Ok(self Ok(self
.db .db
.lock() .lock()
.unwrap()
.query_row( .query_row(
"SELECT id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted "SELECT id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted
FROM known_hosts WHERE host = ? AND port = ?", FROM known_hosts WHERE host = ? AND port = ?",
@@ -284,7 +280,7 @@ impl Store {
} }
pub fn save_known_host(&self, kh: &KnownHost) -> Result<()> { pub fn save_known_host(&self, kh: &KnownHost) -> Result<()> {
self.db.lock().unwrap().execute( self.db.lock().execute(
"INSERT INTO known_hosts (id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted) "INSERT INTO known_hosts (id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(host, port, key_type) DO UPDATE SET ON CONFLICT(host, port, key_type) DO UPDATE SET
@@ -306,7 +302,7 @@ impl Store {
} }
pub fn list_known_hosts(&self) -> Result<Vec<KnownHost>> { pub fn list_known_hosts(&self) -> Result<Vec<KnownHost>> {
let db = self.db.lock().unwrap(); let db = self.db.lock();
let mut st = db.prepare( let mut st = db.prepare(
"SELECT id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted "SELECT id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted
FROM known_hosts ORDER BY host, port", FROM known_hosts ORDER BY host, port",
@@ -316,21 +312,21 @@ impl Store {
} }
pub fn delete_known_host(&self, id: &str) -> Result<()> { pub fn delete_known_host(&self, id: &str) -> Result<()> {
if self.db.lock().unwrap().execute("DELETE FROM known_hosts WHERE id = ?", [id])? == 0 { if self.db.lock().execute("DELETE FROM known_hosts WHERE id = ?", [id])? == 0 {
return Err(format!("storage: known host {id:?} not found").into()); return Err(format!("storage: known host {id:?} not found").into());
} }
Ok(()) Ok(())
} }
pub fn add_audit(&self, action: &str, profile_id: &str, session_id: &str, details: &str) { pub fn add_audit(&self, action: &str, profile_id: &str, session_id: &str, details: &str) {
let _ = self.db.lock().unwrap().execute( let _ = self.db.lock().execute(
"INSERT INTO audit_log (id, timestamp, action, profile_id, session_id, details) VALUES (?, ?, ?, ?, ?, ?)", "INSERT INTO audit_log (id, timestamp, action, profile_id, session_id, details) VALUES (?, ?, ?, ?, ?, ?)",
params![new_id(), fmt_time(&now()), action, profile_id, session_id, details], params![new_id(), fmt_time(&now()), action, profile_id, session_id, details],
); );
} }
pub fn list_audit(&self, limit: i64) -> Result<Vec<AuditEntry>> { pub fn list_audit(&self, limit: i64) -> Result<Vec<AuditEntry>> {
let db = self.db.lock().unwrap(); let db = self.db.lock();
let mut st = db.prepare("SELECT id, timestamp, action, profile_id, session_id, details FROM audit_log ORDER BY timestamp DESC LIMIT ?")?; let mut st = db.prepare("SELECT id, timestamp, action, profile_id, session_id, details FROM audit_log ORDER BY timestamp DESC LIMIT ?")?;
let rows = st let rows = st
.query_map([limit], |r| { .query_map([limit], |r| {
@@ -348,7 +344,7 @@ impl Store {
} }
pub fn irc_last_rooms(&self, profile_id: &str) -> Result<Vec<String>> { pub fn irc_last_rooms(&self, profile_id: &str) -> Result<Vec<String>> {
let db = self.db.lock().unwrap(); let db = self.db.lock();
let mut st = db.prepare("SELECT room_name FROM irc_channel_state WHERE profile_id = ? ORDER BY position, room_name")?; let mut st = db.prepare("SELECT room_name FROM irc_channel_state WHERE profile_id = ? ORDER BY position, room_name")?;
let rows = st.query_map([profile_id], |r| r.get(0))?.collect::<rusqlite::Result<Vec<String>>>()?; let rows = st.query_map([profile_id], |r| r.get(0))?.collect::<rusqlite::Result<Vec<String>>>()?;
Ok(rows) Ok(rows)
@@ -360,7 +356,7 @@ impl Store {
return Err("storage: IRC channel state requires a profile ID".into()); return Err("storage: IRC channel state requires a profile ID".into());
} }
let rooms = dedup_rooms(&[rooms]); let rooms = dedup_rooms(&[rooms]);
let mut db = self.db.lock().unwrap(); let mut db = self.db.lock();
let tx = db.transaction()?; let tx = db.transaction()?;
tx.execute("DELETE FROM irc_channel_state WHERE profile_id = ?", [profile_id])?; tx.execute("DELETE FROM irc_channel_state WHERE profile_id = ?", [profile_id])?;
let now = fmt_time(&now()); let now = fmt_time(&now());
@@ -375,15 +371,14 @@ impl Store {
} }
pub fn preferences(&self) -> AppPreferences { pub fn preferences(&self) -> AppPreferences {
let v: Option<String> = let v: Option<String> = self.db.lock().query_row("SELECT value FROM preferences WHERE key = '_all'", [], |r| r.get(0)).optional().ok().flatten();
self.db.lock().unwrap().query_row("SELECT value FROM preferences WHERE key = '_all'", [], |r| r.get(0)).optional().ok().flatten();
let mut p = v.and_then(|v| serde_json::from_str::<AppPreferences>(&v).ok()).unwrap_or_default(); let mut p = v.and_then(|v| serde_json::from_str::<AppPreferences>(&v).ok()).unwrap_or_default();
p.apply_defaults(); p.apply_defaults();
p p
} }
pub fn save_preferences(&self, p: &AppPreferences) -> Result<()> { pub fn save_preferences(&self, p: &AppPreferences) -> Result<()> {
self.db.lock().unwrap().execute( self.db.lock().execute(
"INSERT INTO preferences (key, value) VALUES ('_all', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", "INSERT INTO preferences (key, value) VALUES ('_all', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
[serde_json::to_string(p)?], [serde_json::to_string(p)?],
)?; )?;
@@ -391,14 +386,14 @@ impl Store {
} }
pub fn list_snippets(&self) -> Result<Vec<Snippet>> { pub fn list_snippets(&self) -> Result<Vec<Snippet>> {
let db = self.db.lock().unwrap(); let db = self.db.lock();
let mut st = db.prepare("SELECT id, name, command, description, folder, tags, created_at, updated_at FROM snippets ORDER BY name ASC")?; let mut st = db.prepare("SELECT id, name, command, description, folder, tags, created_at, updated_at FROM snippets ORDER BY name ASC")?;
let rows = st.query_map([], scan_snippet)?.collect::<rusqlite::Result<Vec<_>>>()?; let rows = st.query_map([], scan_snippet)?.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows) Ok(rows)
} }
pub fn create_snippet(&self, s: &Snippet) -> Result<()> { pub fn create_snippet(&self, s: &Snippet) -> Result<()> {
self.db.lock().unwrap().execute( self.db.lock().execute(
"INSERT INTO snippets (id, name, command, description, folder, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", "INSERT INTO snippets (id, name, command, description, folder, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
params![s.id, s.name, s.command, s.description, s.folder, serde_json::to_string(&s.tags)?, fmt_time(&s.created_at), fmt_time(&s.updated_at)], params![s.id, s.name, s.command, s.description, s.folder, serde_json::to_string(&s.tags)?, fmt_time(&s.created_at), fmt_time(&s.updated_at)],
)?; )?;
@@ -406,7 +401,7 @@ impl Store {
} }
pub fn update_snippet(&self, s: &Snippet) -> Result<()> { pub fn update_snippet(&self, s: &Snippet) -> Result<()> {
self.db.lock().unwrap().execute( self.db.lock().execute(
"UPDATE snippets SET name=?, command=?, description=?, folder=?, tags=?, updated_at=? WHERE id=?", "UPDATE snippets SET name=?, command=?, description=?, folder=?, tags=?, updated_at=? WHERE id=?",
params![s.name, s.command, s.description, s.folder, serde_json::to_string(&s.tags)?, fmt_time(&s.updated_at), s.id], params![s.name, s.command, s.description, s.folder, serde_json::to_string(&s.tags)?, fmt_time(&s.updated_at), s.id],
)?; )?;
@@ -414,7 +409,7 @@ impl Store {
} }
pub fn delete_snippet(&self, id: &str) -> Result<()> { pub fn delete_snippet(&self, id: &str) -> Result<()> {
self.db.lock().unwrap().execute("DELETE FROM snippets WHERE id = ?", [id])?; self.db.lock().execute("DELETE FROM snippets WHERE id = ?", [id])?;
Ok(()) Ok(())
} }
} }
+3 -2
View File
@@ -4,10 +4,11 @@ use nexus_core::models::{Profile, Protocol, SessionState};
use nexus_core::secrets::{Keychain, Secrets, Vault}; use nexus_core::secrets::{Keychain, Secrets, Vault};
use nexus_core::sessions::{Event, HostKeyDecision, Manager}; use nexus_core::sessions::{Event, HostKeyDecision, Manager};
use nexus_core::storage::Store; use nexus_core::storage::Store;
use parking_lot::Mutex;
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::net::TcpListener; use std::net::TcpListener;
use std::sync::Arc;
use std::sync::mpsc; use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
fn manager(name: &str) -> (Arc<Manager>, mpsc::Receiver<Event>, std::path::PathBuf) { fn manager(name: &str) -> (Arc<Manager>, mpsc::Receiver<Event>, std::path::PathBuf) {
@@ -20,7 +21,7 @@ fn manager(name: &str) -> (Arc<Manager>, mpsc::Receiver<Event>, std::path::PathB
let (tx, rx) = mpsc::channel(); let (tx, rx) = mpsc::channel();
let tx = Mutex::new(tx); let tx = Mutex::new(tx);
m.set_sink(Arc::new(move |e| { m.set_sink(Arc::new(move |e| {
let _ = tx.lock().unwrap().send(e); let _ = tx.lock().send(e);
})); }));
(m, rx, dir) (m, rx, dir)
} }
+12 -11
View File
@@ -5,11 +5,12 @@ use nexus_core::clients::Callbacks;
use nexus_core::clients::sftp; use nexus_core::clients::sftp;
use nexus_core::clients::ssh::{HostKeyCheck, SshClient}; use nexus_core::clients::ssh::{HostKeyCheck, SshClient};
use nexus_core::models::{ForwardDirection, JumpHost, PortForward, Profile, Protocol}; use nexus_core::models::{ForwardDirection, JumpHost, PortForward, Profile, Protocol};
use parking_lot::Mutex;
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream}; use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Child, Command}; use std::process::{Child, Command};
use std::sync::{Arc, Mutex}; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
struct Server { struct Server {
@@ -87,7 +88,7 @@ fn callbacks() -> (Callbacks, Seen) {
let seen: Seen = Arc::default(); let seen: Seen = Arc::default();
let s2 = seen.clone(); let s2 = seen.clone();
let mut cb = Callbacks::noop(); let mut cb = Callbacks::noop();
cb.on_data = Arc::new(move |d| s2.lock().unwrap().extend_from_slice(d)); cb.on_data = Arc::new(move |d| s2.lock().extend_from_slice(d));
(cb, seen) (cb, seen)
} }
@@ -96,7 +97,7 @@ fn accept_all() -> (HostKeyCheck, Arc<Mutex<Vec<String>>>) {
let f2 = fps.clone(); let f2 = fps.clone();
( (
Arc::new(move |i| { Arc::new(move |i| {
f2.lock().unwrap().push(format!("{} {}", i.key_type, i.fingerprint)); f2.lock().push(format!("{} {}", i.key_type, i.fingerprint));
Ok(()) Ok(())
}), }),
fps, fps,
@@ -106,7 +107,7 @@ fn accept_all() -> (HostKeyCheck, Arc<Mutex<Vec<String>>>) {
fn wait_for(seen: &Seen, needle: &str) -> bool { fn wait_for(seen: &Seen, needle: &str) -> bool {
let start = Instant::now(); let start = Instant::now();
while start.elapsed() < Duration::from_secs(10) { while start.elapsed() < Duration::from_secs(10) {
if String::from_utf8_lossy(&seen.lock().unwrap()).contains(needle) { if String::from_utf8_lossy(&seen.lock()).contains(needle) {
return true; return true;
} }
std::thread::sleep(Duration::from_millis(50)); std::thread::sleep(Duration::from_millis(50));
@@ -160,11 +161,11 @@ fn shell_exec_forwards_sftp() {
let (check, fps) = accept_all(); let (check, fps) = accept_all();
let c = SshClient::new(&profile(&s), cb); let c = SshClient::new(&profile(&s), cb);
c.connect(check, None, None).unwrap(); c.connect(check, None, None).unwrap();
assert!(fps.lock().unwrap()[0].starts_with("ssh-ed25519 SHA256:")); assert!(fps.lock()[0].starts_with("ssh-ed25519 SHA256:"));
c.resize(120, 40).unwrap(); c.resize(120, 40).unwrap();
c.write(b"echo nexus-$((40+2)) $COLUMNS\n").unwrap(); c.write(b"echo nexus-$((40+2)) $COLUMNS\n").unwrap();
assert!(wait_for(&seen, "nexus-42"), "shell output: {}", String::from_utf8_lossy(&seen.lock().unwrap())); assert!(wait_for(&seen, "nexus-42"), "shell output: {}", String::from_utf8_lossy(&seen.lock()));
assert_eq!(c.run_command("echo hi").unwrap(), "hi"); assert_eq!(c.run_command("echo hi").unwrap(), "hi");
assert!(c.ping().unwrap() >= 0); assert!(c.ping().unwrap() >= 0);
@@ -222,16 +223,16 @@ fn close_fires_once_and_remote_exit_closes() {
let (mut cb, _) = callbacks(); let (mut cb, _) = callbacks();
let n = Arc::new(Mutex::new(0)); let n = Arc::new(Mutex::new(0));
let n2 = n.clone(); let n2 = n.clone();
cb.on_close = Arc::new(move || *n2.lock().unwrap() += 1); cb.on_close = Arc::new(move || *n2.lock() += 1);
let c = SshClient::new(&profile(&s), cb); let c = SshClient::new(&profile(&s), cb);
c.connect(accept_all().0, None, None).unwrap(); c.connect(accept_all().0, None, None).unwrap();
c.write(b"exit\n").unwrap(); c.write(b"exit\n").unwrap();
let start = Instant::now(); let start = Instant::now();
while *n.lock().unwrap() == 0 && start.elapsed() < Duration::from_secs(10) { while *n.lock() == 0 && start.elapsed() < Duration::from_secs(10) {
std::thread::sleep(Duration::from_millis(50)); std::thread::sleep(Duration::from_millis(50));
} }
c.close(); c.close();
assert_eq!(*n.lock().unwrap(), 1); assert_eq!(*n.lock(), 1);
} }
#[test] #[test]
@@ -252,7 +253,7 @@ fn jump_host_chain() {
let (check, fps) = accept_all(); let (check, fps) = accept_all();
let c = SshClient::new(&p, Callbacks::noop()); let c = SshClient::new(&p, Callbacks::noop());
c.connect(check, None, None).unwrap(); c.connect(check, None, None).unwrap();
assert_eq!(fps.lock().unwrap().len(), 2, "jump and target both verified"); assert_eq!(fps.lock().len(), 2, "jump and target both verified");
assert_eq!(c.run_command("echo via-jump").unwrap(), "via-jump"); assert_eq!(c.run_command("echo via-jump").unwrap(), "via-jump");
c.close(); c.close();
} }
@@ -281,6 +282,6 @@ fn agent_auth_and_forwarding() {
let cl = SshClient::new(&p, cb); let cl = SshClient::new(&p, cb);
cl.connect(accept_all().0, None, None).unwrap(); cl.connect(accept_all().0, None, None).unwrap();
cl.write(b"ssh-add -l >/dev/null && echo agent-$((1+1))-ok\n").unwrap(); cl.write(b"ssh-add -l >/dev/null && echo agent-$((1+1))-ok\n").unwrap();
assert!(wait_for(&seen, "agent-2-ok"), "forwarded agent not usable: {}", String::from_utf8_lossy(&seen.lock().unwrap())); assert!(wait_for(&seen, "agent-2-ok"), "forwarded agent not usable: {}", String::from_utf8_lossy(&seen.lock()));
cl.close(); cl.close();
} }