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:
Generated
+2
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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() {
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 } => {
|
||||||
|
|||||||
@@ -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")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)();
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 -------------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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:?}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user