diff --git a/.gitignore b/.gitignore index 37c1144..8e46064 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,9 @@ dist **/node_modules src-tauri/target/ **/creddy.db -# used for DATABASE_URL, which is system-specific +# .env is system-specific .env +.vscode # just in case credentials* diff --git a/doc/todo.md b/doc/todo.md index f032674..e7a38b3 100644 --- a/doc/todo.md +++ b/doc/todo.md @@ -16,3 +16,4 @@ * Generalize Request across both credentials and terminal launch? * Make hotkey configuration a little more tolerant of slight mistiming * Distinguish between request that was denied and request that was canceled (e.g. due to error) +* Use atomic types for primitive state values instead of RwLock'd types diff --git a/package-lock.json b/package-lock.json index 13e718e..0a9206a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "creddy", - "version": "0.3.4", + "version": "0.3.3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "creddy", - "version": "0.3.4", + "version": "0.3.3", "dependencies": { "@tauri-apps/api": "^1.0.2", "daisyui": "^2.51.5" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 25e9d84..737bfca 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1047,7 +1047,6 @@ dependencies = [ "clap", "dirs 5.0.1", "is-terminal", - "netstat2", "once_cell", "serde", "serde_json", @@ -1062,6 +1061,7 @@ dependencies = [ "thiserror", "tokio", "which", + "windows 0.51.1", ] [[package]] @@ -2641,20 +2641,6 @@ dependencies = [ "jni-sys", ] -[[package]] -name = "netstat2" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0faa3f4ad230fd2bf2a5dad71476ecbaeaed904b3c7e7e5b1f266c415c03761f" -dependencies = [ - "bitflags 1.3.2", - "byteorder", - "libc", - "num-derive", - "num-traits", - "thiserror", -] - [[package]] name = "new_debug_unreachable" version = "1.0.4" @@ -2708,17 +2694,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "num-derive" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "num-integer" version = "0.1.45" @@ -5261,6 +5236,16 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows" +version = "0.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca229916c5ee38c2f2bc1e9d8f04df975b4bd93f9955dc69fabb5d91270045c9" +dependencies = [ + "windows-core", + "windows-targets 0.48.5", +] + [[package]] name = "windows-bindgen" version = "0.39.0" @@ -5271,6 +5256,15 @@ dependencies = [ "windows-tokens", ] +[[package]] +name = "windows-core" +version = "0.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-implement" version = "0.39.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 475298c..93f1047 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -30,7 +30,6 @@ tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-wo sodiumoxide = "0.2.7" tokio = { version = ">=1.19", features = ["full"] } sqlx = { version = "0.6.2", features = ["sqlite", "runtime-tokio-rustls"] } -netstat2 = "0.9.1" sysinfo = "0.26.8" aws-types = "0.52.0" aws-sdk-sts = "0.22.0" @@ -47,6 +46,7 @@ is-terminal = "0.4.7" argon2 = { version = "0.5.0", features = ["std"] } chacha20poly1305 = { version = "0.10.1", features = ["std"] } which = "4.4.0" +windows = { version = "0.51.1", features = ["Win32_Foundation", "Win32_System_Pipes"] } [features] # by default Tauri runs in production mode diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs index 5db14c3..7b53eea 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -19,6 +19,7 @@ use crate::{ ipc, server::Server, errors::*, + shortcuts, state::AppState, tray, }; @@ -93,16 +94,18 @@ async fn setup(app: &mut App) -> Result<(), Box> { }; let session = Session::load(&pool).await?; - let srv = Server::new(conf.listen_addr, conf.listen_port, app.handle()).await?; + Server::start(app.handle())?; config::set_auto_launch(conf.start_on_login)?; if let Err(_e) = config::set_auto_launch(conf.start_on_login) { setup_errors.push("Error: Failed to manage autolaunch.".into()); } - if let Err(e) = config::register_hotkeys(&conf.hotkeys) { - conf.hotkeys.show_window.enabled = false; - conf.hotkeys.launch_terminal.enabled = false; - setup_errors.push(format!("{e}")); + + // if hotkeys fail to register, disable them so that this error doesn't have to keep showing up + if let Err(_e) = shortcuts::register_hotkeys(&conf.hotkeys) { + conf.hotkeys.disable_all(); + conf.save(&pool).await?; + setup_errors.push("Failed to register hotkeys. Hotkey settings have been disabled.".into()); } // if session is empty, this is probably the first launch, so don't autohide @@ -112,7 +115,7 @@ async fn setup(app: &mut App) -> Result<(), Box> { .show()?; } - let state = AppState::new(conf, session, srv, pool, setup_errors); + let state = AppState::new(conf, session, pool, setup_errors); app.manage(state); Ok(()) } diff --git a/src-tauri/src/bin/creddy_cli.rs b/src-tauri/src/bin/creddy_cli.rs index 02ebd4b..59e4c4f 100644 --- a/src-tauri/src/bin/creddy_cli.rs +++ b/src-tauri/src/bin/creddy_cli.rs @@ -19,13 +19,15 @@ fn main() { let res = match args.subcommand() { None | Some(("run", _)) => launch_gui(), - Some(("show", m)) => cli::show(m), + Some(("get", m)) => cli::get(m), Some(("exec", m)) => cli::exec(m), - _ => unreachable!(), + Some(("shortcut", m)) => cli::invoke_shortcut(m), + _ => unreachable!("Unknown subcommand"), }; if let Err(e) = res { eprintln!("Error: {e}"); + process::exit(1); } } diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs index ea91820..c4c1b2d 100644 --- a/src-tauri/src/cli.rs +++ b/src-tauri/src/cli.rs @@ -1,24 +1,33 @@ use std::ffi::OsString; use std::process::Command as ChildCommand; -#[cfg(unix)] -use std::os::unix::process::CommandExt; +#[cfg(windows)] +use std::time::Duration; use clap::{ Command, - Arg, - ArgMatches, - ArgAction + Arg, + ArgMatches, + ArgAction, + builder::PossibleValuesParser, }; -use tokio::{ - net::TcpStream, - io::{AsyncReadExt, AsyncWriteExt}, +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use crate::credentials::Credentials; +use crate::errors::*; +use crate::server::{Request, Response}; +use crate::shortcuts::ShortcutAction; + +#[cfg(unix)] +use { + std::os::unix::process::CommandExt, + tokio::net::UnixStream, }; - -use crate::app; -use crate::config::AppConfig; -use crate::credentials::{BaseCredentials, SessionCredentials}; -use crate::errors::*; +#[cfg(windows)] +use { + tokio::net::windows::named_pipe::{NamedPipeClient, ClientOptions}, + windows::Win32::Foundation::ERROR_PIPE_BUSY, +}; pub fn parser() -> Command<'static> { @@ -30,8 +39,8 @@ pub fn parser() -> Command<'static> { .about("Launch Creddy") ) .subcommand( - Command::new("show") - .about("Fetch and display AWS credentials") + Command::new("get") + .about("Request AWS credentials from Creddy and output to stdout") .arg( Arg::new("base") .short('b') @@ -56,13 +65,26 @@ pub fn parser() -> Command<'static> { .multiple_values(true) ) ) + .subcommand( + Command::new("shortcut") + .about("Invoke an action normally trigged by hotkey (e.g. launch terminal)") + .arg( + Arg::new("action") + .value_parser( + PossibleValuesParser::new(["show_window", "launch_terminal"]) + ) + ) + ) } -pub fn show(args: &ArgMatches) -> Result<(), CliError> { +pub fn get(args: &ArgMatches) -> Result<(), CliError> { let base = args.get_one("base").unwrap_or(&false); - let creds = get_credentials(*base)?; - println!("{creds}"); + let output = match get_credentials(*base)? { + Credentials::Base(creds) => serde_json::to_string(&creds).unwrap(), + Credentials::Session(creds) => serde_json::to_string(&creds).unwrap(), + }; + println!("{output}"); Ok(()) } @@ -76,18 +98,16 @@ pub fn exec(args: &ArgMatches) -> Result<(), CliError> { let mut cmd = ChildCommand::new(cmd_name); cmd.args(cmd_line); - if base { - let creds: BaseCredentials = serde_json::from_str(&get_credentials(base)?) - .map_err(|_| RequestError::InvalidJson)?; - cmd.env("AWS_ACCESS_KEY_ID", creds.access_key_id); - cmd.env("AWS_SECRET_ACCESS_KEY", creds.secret_access_key); - } - else { - let creds: SessionCredentials = serde_json::from_str(&get_credentials(base)?) - .map_err(|_| RequestError::InvalidJson)?; - cmd.env("AWS_ACCESS_KEY_ID", creds.access_key_id); - cmd.env("AWS_SECRET_ACCESS_KEY", creds.secret_access_key); - cmd.env("AWS_SESSION_TOKEN", creds.token); + match get_credentials(base)? { + Credentials::Base(creds) => { + cmd.env("AWS_ACCESS_KEY_ID", creds.access_key_id); + cmd.env("AWS_SECRET_ACCESS_KEY", creds.secret_access_key); + }, + Credentials::Session(creds) => { + cmd.env("AWS_ACCESS_KEY_ID", creds.access_key_id); + cmd.env("AWS_SECRET_ACCESS_KEY", creds.secret_access_key); + cmd.env("AWS_SESSION_TOKEN", creds.session_token); + } } #[cfg(unix)] @@ -121,41 +141,63 @@ pub fn exec(args: &ArgMatches) -> Result<(), CliError> { } -#[tokio::main] -async fn get_credentials(base: bool) -> Result { - let pool = app::connect_db().await?; - let config = AppConfig::load(&pool).await?; - let path = if base {"/creddy/base-credentials"} else {"/"}; +pub fn invoke_shortcut(args: &ArgMatches) -> Result<(), CliError> { + let action = match args.get_one::("action").map(|s| s.as_str()) { + Some("show_window") => ShortcutAction::ShowWindow, + Some("launch_terminal") => ShortcutAction::LaunchTerminal, + Some(&_) | None => unreachable!("Unknown shortcut action"), // guaranteed by clap + }; - let mut stream = TcpStream::connect((config.listen_addr, config.listen_port)).await?; - let req = format!("GET {path} HTTP/1.0\r\n\r\n"); - stream.write_all(req.as_bytes()).await?; - - // some day we'll have a proper HTTP parser - let mut buf = vec![0; 8192]; - stream.read_to_end(&mut buf).await?; - - let status = buf.split(|&c| &[c] == b" ") - .skip(1) - .next() - .ok_or(RequestError::MalformedHttpResponse)?; - - if status != b"200" { - let s = String::from_utf8_lossy(status).to_string(); - return Err(RequestError::Failed(s)); + let req = Request::InvokeShortcut(action); + match make_request(&req) { + Ok(Response::Empty) => Ok(()), + Ok(r) => Err(RequestError::Unexpected(r).into()), + Err(e) => Err(e.into()), } - - let break_idx = buf.windows(4) - .position(|w| w == b"\r\n\r\n") - .ok_or(RequestError::MalformedHttpResponse)?; - let body = &buf[(break_idx + 4)..]; - - let creds_str = std::str::from_utf8(body) - .map_err(|_| RequestError::MalformedHttpResponse)? - .to_string(); - - if creds_str == "Denied!" { - return Err(RequestError::Rejected); - } - Ok(creds_str) +} + + +fn get_credentials(base: bool) -> Result { + let req = Request::GetAwsCredentials { base }; + match make_request(&req) { + Ok(Response::Aws(creds)) => Ok(creds), + Ok(r) => Err(RequestError::Unexpected(r)), + Err(e) => Err(e), + } +} + + +#[tokio::main] +async fn make_request(req: &Request) -> Result { + let mut data = serde_json::to_string(req).unwrap(); + // server expects newline marking end of request + data.push('\n'); + + let mut stream = connect().await?; + stream.write_all(&data.as_bytes()).await?; + + let mut buf = Vec::with_capacity(1024); + stream.read_to_end(&mut buf).await?; + let res: Result = serde_json::from_slice(&buf)?; + Ok(res?) +} + + +#[cfg(windows)] +async fn connect() -> Result { + // apparently attempting to connect can fail if there's already a client connected + loop { + match ClientOptions::new().open(r"\\.\pipe\creddy-requests") { + Ok(stream) => return Ok(stream), + Err(e) if e.raw_os_error() == Some(ERROR_PIPE_BUSY.0 as i32) => (), + Err(e) => return Err(e), + } + tokio::time::sleep(Duration::from_millis(10)).await; + } +} + + +#[cfg(unix)] +async fn connect() -> Result { + UnixStream::connect("/tmp/creddy.sock").await } diff --git a/src-tauri/src/clientinfo.rs b/src-tauri/src/clientinfo.rs index f8d4b6f..4cff73d 100644 --- a/src-tauri/src/clientinfo.rs +++ b/src-tauri/src/clientinfo.rs @@ -1,76 +1,92 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; -use netstat2::{AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo}; -use tauri::Manager; use sysinfo::{System, SystemExt, Pid, PidExt, ProcessExt}; use serde::{Serialize, Deserialize}; -use crate::{ - app::APP, - errors::*, - config::AppConfig, - state::AppState, -}; +use crate::errors::*; #[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Hash)] pub struct Client { pub pid: u32, - pub exe: PathBuf, + pub exe: Option, } -async fn get_associated_pids(local_port: u16) -> Result, netstat2::error::Error> { - let state = APP.get().unwrap().state::(); - let AppConfig { - listen_addr: app_listen_addr, - listen_port: app_listen_port, - .. - } = *state.config.read().await; +pub fn get_process_parent_info(pid: u32) -> Result { + dbg!(pid); + let sys_pid = Pid::from_u32(pid); + let mut sys = System::new(); + sys.refresh_process(sys_pid); + let proc = sys.process(sys_pid) + .ok_or(ClientInfoError::ProcessNotFound)?; - let sockets_iter = netstat2::iterate_sockets_info( - AddressFamilyFlags::IPV4, - ProtocolFlags::TCP - )?; - for item in sockets_iter { - let sock_info = item?; - let proto_info = match sock_info.protocol_socket_info { - ProtocolSocketInfo::Tcp(tcp_info) => tcp_info, - ProtocolSocketInfo::Udp(_) => {continue;} - }; + let parent_pid_sys = proc.parent() + .ok_or(ClientInfoError::ParentPidNotFound)?; + sys.refresh_process(parent_pid_sys); + let parent = sys.process(parent_pid_sys) + .ok_or(ClientInfoError::ParentProcessNotFound)?; - if proto_info.local_port == local_port - && proto_info.remote_port == app_listen_port - && proto_info.local_addr == app_listen_addr - && proto_info.remote_addr == app_listen_addr - { - return Ok(sock_info.associated_pids) - } - } - Ok(vec![]) + let exe = match parent.exe() { + p if p == Path::new("") => None, + p => Some(PathBuf::from(p)), + }; + + Ok(Client { pid: parent_pid_sys.as_u32(), exe }) } +// async fn get_associated_pids(local_port: u16) -> Result, netstat2::error::Error> { +// let state = APP.get().unwrap().state::(); +// let AppConfig { +// listen_addr: app_listen_addr, +// listen_port: app_listen_port, +// .. +// } = *state.config.read().await; + +// let sockets_iter = netstat2::iterate_sockets_info( +// AddressFamilyFlags::IPV4, +// ProtocolFlags::TCP +// )?; +// for item in sockets_iter { +// let sock_info = item?; +// let proto_info = match sock_info.protocol_socket_info { +// ProtocolSocketInfo::Tcp(tcp_info) => tcp_info, +// ProtocolSocketInfo::Udp(_) => {continue;} +// }; + +// if proto_info.local_port == local_port +// && proto_info.remote_port == app_listen_port +// && proto_info.local_addr == app_listen_addr +// && proto_info.remote_addr == app_listen_addr +// { +// return Ok(sock_info.associated_pids) +// } +// } +// Ok(vec![]) +// } + + // Theoretically, on some systems, multiple processes can share a socket -pub async fn get_clients(local_port: u16) -> Result>, ClientInfoError> { - let mut clients = Vec::new(); - let mut sys = System::new(); - for p in get_associated_pids(local_port).await? { - let pid = Pid::from_u32(p); - sys.refresh_process(pid); - let proc = sys.process(pid) - .ok_or(ClientInfoError::ProcessNotFound)?; +// pub async fn get_clients(local_port: u16) -> Result>, ClientInfoError> { +// let mut clients = Vec::new(); +// let mut sys = System::new(); +// for p in get_associated_pids(local_port).await? { +// let pid = Pid::from_u32(p); +// sys.refresh_process(pid); +// let proc = sys.process(pid) +// .ok_or(ClientInfoError::ProcessNotFound)?; - let client = Client { - pid: p, - exe: proc.exe().to_path_buf(), - }; - clients.push(Some(client)); - } +// let client = Client { +// pid: p, +// exe: proc.exe().to_path_buf(), +// }; +// clients.push(Some(client)); +// } - if clients.is_empty() { - clients.push(None); - } +// if clients.is_empty() { +// clients.push(None); +// } - Ok(clients) -} +// Ok(clients) +// } diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 96cf5f6..5cfb352 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -1,15 +1,9 @@ -use std::net::Ipv4Addr; use std::path::PathBuf; use auto_launch::AutoLaunchBuilder; use is_terminal::IsTerminal; use serde::{Serialize, Deserialize}; use sqlx::SqlitePool; -use tauri::{ - Manager, - GlobalShortcutManager, - async_runtime as rt, -}; use crate::errors::*; @@ -39,13 +33,16 @@ pub struct HotkeysConfig { pub launch_terminal: Hotkey, } +impl HotkeysConfig { + pub fn disable_all(&mut self) { + self.show_window.enabled = false; + self.launch_terminal.enabled = false; + } +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct AppConfig { - #[serde(default = "default_listen_addr")] - pub listen_addr: Ipv4Addr, - #[serde(default = "default_listen_port")] - pub listen_port: u16, #[serde(default = "default_rehide_ms")] pub rehide_ms: u64, #[serde(default = "default_start_minimized")] @@ -62,8 +59,6 @@ pub struct AppConfig { impl Default for AppConfig { fn default() -> Self { AppConfig { - listen_addr: default_listen_addr(), - listen_port: default_listen_port(), rehide_ms: default_rehide_ms(), start_minimized: default_start_minimized(), start_on_login: default_start_on_login(), @@ -144,16 +139,6 @@ pub fn get_or_create_db_path() -> Result { } -fn default_listen_port() -> u16 { - if cfg!(debug_assertions) { - 12_345 - } - else { - 19_923 - } -} - - fn default_term_config() -> TermConfig { #[cfg(windows)] { @@ -200,52 +185,7 @@ fn default_hotkey_config() -> HotkeysConfig { } } -// note: will panic if called before APP is set -pub fn register_hotkeys(hotkeys: &HotkeysConfig) -> tauri::Result<()> { - let app = crate::app::APP.get().unwrap(); - - let mut manager = app.global_shortcut_manager(); - if let Err(_e) = manager.unregister_all() { - if !hotkeys.show_window.enabled && !hotkeys.launch_terminal.enabled { - // if both are disabled and we failed to unregister, then probably - // we also failed to register in the first place - return Ok(()) - } - } - - if hotkeys.show_window.enabled { - let handle = app.app_handle(); - manager.register( - &hotkeys.show_window.keys, - move || { - handle.get_window("main") - .map(|w| w.show().error_popup("Failed to show")) - .ok_or(HandlerError::NoMainWindow) - .error_popup("No main window"); - }, - )?; - } - - if hotkeys.launch_terminal.enabled { - // register() doesn't take an async fn, so we have to use spawn - manager.register( - &hotkeys.launch_terminal.keys, - || { - rt::spawn(async { - crate::terminal::launch(false) - .await - .error_popup("Failed to launch"); - }); - } - )?; - } - - Ok(()) -} - - -fn default_listen_addr() -> Ipv4Addr { Ipv4Addr::LOCALHOST } fn default_rehide_ms() -> u64 { 1000 } // start minimized and on login only in production mode fn default_start_minimized() -> bool { !cfg!(debug_assertions) } diff --git a/src-tauri/src/credentials.rs b/src-tauri/src/credentials.rs index 91b1588..c54c516 100644 --- a/src-tauri/src/credentials.rs +++ b/src-tauri/src/credentials.rs @@ -162,9 +162,10 @@ impl BaseCredentials { #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct SessionCredentials { + pub version: usize, pub access_key_id: String, pub secret_access_key: String, - pub token: String, + pub session_token: String, #[serde(serialize_with = "serialize_expiration")] #[serde(deserialize_with = "deserialize_expiration")] pub expiration: DateTime, @@ -198,7 +199,7 @@ impl SessionCredentials { let secret_access_key = aws_session.secret_access_key() .ok_or(GetSessionError::EmptyResponse)? .to_string(); - let token = aws_session.session_token() + let session_token = aws_session.session_token() .ok_or(GetSessionError::EmptyResponse)? .to_string(); let expiration = aws_session.expiration() @@ -206,9 +207,10 @@ impl SessionCredentials { .clone(); let session_creds = SessionCredentials { + version: 1, access_key_id, secret_access_key, - token, + session_token, expiration, }; @@ -230,6 +232,14 @@ impl SessionCredentials { } } + +#[derive(Debug, Serialize, Deserialize)] +pub enum Credentials { + Base(BaseCredentials), + Session(SessionCredentials), +} + + fn serialize_expiration(exp: &DateTime, serializer: S) -> Result where S: Serializer { diff --git a/src-tauri/src/errors.rs b/src-tauri/src/errors.rs index f56a2df..de7c823 100644 --- a/src-tauri/src/errors.rs +++ b/src-tauri/src/errors.rs @@ -2,6 +2,7 @@ use std::error::Error; use std::convert::AsRef; use std::ffi::OsString; use std::sync::mpsc; +use std::string::FromUtf8Error; use strum_macros::AsRefStr; use thiserror::Error as ThisError; @@ -17,15 +18,22 @@ use tauri::api::dialog::{ MessageDialogBuilder, MessageDialogKind, }; -use serde::{Serialize, Serializer, ser::SerializeMap}; +use serde::{ + Serialize, + Serializer, + ser::SerializeMap, + Deserialize, +}; -pub trait ErrorPopup { +pub trait ShowError { fn error_popup(self, title: &str); fn error_popup_nowait(self, title: &str); + fn error_print(self); + fn error_print_prefix(self, prefix: &str); } -impl ErrorPopup for Result<(), E> { +impl ShowError for Result<(), E> { fn error_popup(self, title: &str) { if let Err(e) = self { let (tx, rx) = mpsc::channel(); @@ -44,6 +52,18 @@ impl ErrorPopup for Result<(), E> { .show(|_| {}) } } + + fn error_print(self) { + if let Err(e) = self { + eprintln!("{e}"); + } + } + + fn error_print_prefix(self, prefix: &str) { + if let Err(e) = self { + eprintln!("{prefix}: {e}"); + } + } } @@ -137,12 +157,14 @@ pub enum SendResponseError { pub enum HandlerError { #[error("Error writing to stream: {0}")] StreamIOError(#[from] std::io::Error), - // #[error("Received invalid UTF-8 in request")] - // InvalidUtf8, + #[error("Received invalid UTF-8 in request")] + InvalidUtf8(#[from] FromUtf8Error), #[error("HTTP request malformed")] - BadRequest(Vec), + BadRequest(#[from] serde_json::Error), #[error("HTTP request too large")] RequestTooLarge, + #[error("Internal server error")] + Internal, #[error("Error accessing credentials: {0}")] NoCredentials(#[from] GetCredentialsError), #[error("Error getting client details: {0}")] @@ -151,6 +173,17 @@ pub enum HandlerError { Tauri(#[from] tauri::Error), #[error("No main application window found")] NoMainWindow, + #[error("Request was denied")] + Denied, +} + + +#[derive(Debug, ThisError, AsRefStr)] +pub enum WindowError { + #[error("Failed to find main application window")] + NoMainWindow, + #[error(transparent)] + ManageFailure(#[from] tauri::Error), } @@ -207,26 +240,50 @@ pub enum CryptoError { pub enum ClientInfoError { #[error("Found PID for client socket, but no corresponding process")] ProcessNotFound, - #[error("Couldn't get client socket details: {0}")] - NetstatError(#[from] netstat2::error::Error), + #[error("Could not determine parent PID of connected client")] + ParentPidNotFound, + #[error("Found PID for parent process of client, but no corresponding process")] + ParentProcessNotFound, + #[cfg(windows)] + #[error("Could not determine PID of connected client")] + WindowsError(#[from] windows::core::Error), + #[error(transparent)] + Io(#[from] std::io::Error), +} + + +// Technically also an error, but formatted as a struct for easy deserialization +#[derive(Debug, Serialize, Deserialize)] +pub struct ServerError { + code: String, + msg: String, +} + +impl std::fmt::Display for ServerError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> Result<(), std::fmt::Error> { + write!(f, "{} ({})", self.msg, self.code)?; + Ok(()) + } } // Errors encountered while requesting credentials via CLI (creddy show, creddy exec) #[derive(Debug, ThisError, AsRefStr)] pub enum RequestError { - #[error("Credentials request failed: HTTP {0}")] - Failed(String), - #[error("Credentials request was rejected")] - Rejected, - #[error("Couldn't interpret the server's response")] - MalformedHttpResponse, + #[error("Error response from server: {0}")] + Server(ServerError), + #[error("Unexpected response from server")] + Unexpected(crate::server::Response), #[error("The server did not respond with valid JSON")] - InvalidJson, + InvalidJson(#[from] serde_json::Error), #[error("Error reading/writing stream: {0}")] StreamIOError(#[from] std::io::Error), - #[error("Error loading configuration data: {0}")] - Setup(#[from] SetupError), +} + +impl From for RequestError { + fn from(s: ServerError) -> Self { + Self::Server(s) + } } @@ -291,6 +348,7 @@ impl Serialize for SerializeWrapper<&GetSessionTokenError> { impl_serialize_basic!(SetupError); impl_serialize_basic!(GetCredentialsError); impl_serialize_basic!(ClientInfoError); +impl_serialize_basic!(WindowError); impl Serialize for HandlerError { @@ -298,13 +356,6 @@ impl Serialize for HandlerError { let mut map = serializer.serialize_map(None)?; map.serialize_entry("code", self.as_ref())?; map.serialize_entry("msg", &format!("{self}"))?; - - match self { - HandlerError::NoCredentials(src) => map.serialize_entry("source", &src)?, - HandlerError::ClientInfo(src) => map.serialize_entry("source", &src)?, - _ => serialize_upstream_err(self, &mut map)?, - } - map.end() } } @@ -353,6 +404,8 @@ impl Serialize for UnlockError { match self { UnlockError::GetSession(src) => map.serialize_entry("source", &src)?, + // The string representation of the AEAD error is not very helpful, so skip it + UnlockError::Crypto(_src) => map.serialize_entry("source", &None::<&str>)?, _ => serialize_upstream_err(self, &mut map)?, } map.end() diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 0b9428d..03e487a 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -10,9 +10,9 @@ use crate::terminal; #[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Request { +pub struct AwsRequestNotification { pub id: u64, - pub clients: Vec>, + pub client: Client, pub base: bool, } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2c1fbeb..70c2e98 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,5 +7,6 @@ mod clientinfo; mod ipc; mod state; mod server; +mod shortcuts; mod terminal; mod tray; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 982367e..9e0fc5e 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -6,7 +6,7 @@ use creddy::{ app, cli, - errors::ErrorPopup, + errors::ShowError, }; @@ -16,12 +16,14 @@ fn main() { app::run().error_popup("Creddy failed to start"); Ok(()) }, - Some(("show", m)) => cli::show(m), + Some(("get", m)) => cli::get(m), Some(("exec", m)) => cli::exec(m), + Some(("shortcut", m)) => cli::invoke_shortcut(m), _ => unreachable!(), }; if let Err(e) = res { eprintln!("Error: {e}"); + std::process::exit(1); } } diff --git a/src-tauri/src/server.rs b/src-tauri/src/server.rs deleted file mode 100644 index 241afcf..0000000 --- a/src-tauri/src/server.rs +++ /dev/null @@ -1,275 +0,0 @@ -use core::time::Duration; -use std::io; -use std::net::{ - Ipv4Addr, - SocketAddr, - SocketAddrV4, -}; -use tokio::net::{ - TcpListener, - TcpStream, -}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::sync::oneshot::{self, Sender, Receiver}; -use tokio::time::sleep; - -use tauri::{AppHandle, Manager}; -use tauri::async_runtime as rt; -use tauri::async_runtime::JoinHandle; - -use crate::{clientinfo, clientinfo::Client}; -use crate::errors::*; -use crate::ipc::{Request, Approval}; -use crate::state::AppState; - - -#[derive(Debug)] -pub struct RequestWaiter { - pub rehide_after: bool, - pub sender: Option>, -} - -impl RequestWaiter { - pub fn notify(&mut self, approval: Approval) -> Result<(), SendResponseError> { - let chan = self.sender - .take() - .ok_or(SendResponseError::Fulfilled)?; - - chan.send(approval) - .map_err(|_| SendResponseError::Abandoned) - } -} - - -struct Handler { - request_id: u64, - stream: TcpStream, - rehide_after: bool, - receiver: Option>, - app: AppHandle, -} - -impl Handler { - async fn new(stream: TcpStream, app: AppHandle) -> Result { - let state = app.state::(); - - // determine whether we should re-hide the window after handling this request - let is_currently_visible = app.get_window("main") - .ok_or(HandlerError::NoMainWindow)? - .is_visible()?; - let rehide_after = state.current_rehide_status() - .await - .unwrap_or(!is_currently_visible); - - let (chan_send, chan_recv) = oneshot::channel(); - let waiter = RequestWaiter {rehide_after, sender: Some(chan_send)}; - let request_id = state.register_request(waiter).await; - let handler = Handler { - request_id, - stream, - rehide_after, - receiver: Some(chan_recv), - app - }; - Ok(handler) - } - - async fn handle(mut self) { - if let Err(e) = self.try_handle().await { - eprintln!("{e}"); - } - let state = self.app.state::(); - state.unregister_request(self.request_id).await; - } - - async fn try_handle(&mut self) -> Result<(), HandlerError> { - let req_path = self.recv_request().await?; - let clients = self.get_clients().await?; - if self.includes_banned(&clients).await { - self.stream.write(b"HTTP/1.0 403 Access Denied\r\n\r\n").await?; - return Ok(()) - } - let base = req_path == b"/creddy/base-credentials"; - - let req = Request {id: self.request_id, clients, base}; - self.app.emit_all("credentials-request", &req)?; - self.show_window()?; - - match self.wait_for_response().await? { - Approval::Approved => { - let state = self.app.state::(); - let creds = if base { - state.serialize_base_creds().await? - } - else { - state.serialize_session_creds().await? - }; - self.send_body(creds.as_bytes()).await?; - }, - Approval::Denied => { - let state = self.app.state::(); - for client in req.clients { - state.add_ban(client).await; - } - self.send_body(b"Denied!").await?; - self.stream.shutdown().await?; - } - } - - // only hide the window if a) it was hidden to start with - // and b) there are no other pending requests - let state = self.app.state::(); - let delay = { - let config = state.config.read().await; - Duration::from_millis(config.rehide_ms) - }; - sleep(delay).await; - - if self.rehide_after && state.req_count().await == 1 { - self.app - .get_window("main") - .ok_or(HandlerError::NoMainWindow)? - .hide()?; - } - - Ok(()) - } - - async fn recv_request(&mut self) -> Result, HandlerError> { - let mut buf = vec![0; 8192]; // it's what tokio's BufReader uses - let mut n = 0; - loop { - n += self.stream.read(&mut buf[n..]).await?; - if n >= 4 && &buf[(n - 4)..n] == b"\r\n\r\n" {break;} - if n == buf.len() {return Err(HandlerError::RequestTooLarge);} - } - - let path = buf.split(|&c| &[c] == b" ") - .skip(1) - .next() - .ok_or(HandlerError::BadRequest(buf.clone()))?; - - #[cfg(debug_assertions)] { - println!("Path: {}", std::str::from_utf8(&path).unwrap()); - println!("{}", std::str::from_utf8(&buf).unwrap()); - } - - Ok(path.into()) - } - - async fn get_clients(&self) -> Result>, HandlerError> { - let peer_addr = match self.stream.peer_addr()? { - SocketAddr::V4(addr) => addr, - _ => unreachable!(), // we only listen on IPv4 - }; - let clients = clientinfo::get_clients(peer_addr.port()).await?; - Ok(clients) - } - - async fn includes_banned(&self, clients: &Vec>) -> bool { - let state = self.app.state::(); - for client in clients { - if state.is_banned(client).await { - return true; - } - } - false - } - - fn show_window(&self) -> Result<(), HandlerError> { - let window = self.app.get_window("main").ok_or(HandlerError::NoMainWindow)?; - if !window.is_visible()? { - window.unminimize()?; - window.show()?; - } - window.set_focus()?; - Ok(()) - } - - async fn wait_for_response(&mut self) -> Result { - self.stream.write(b"HTTP/1.0 200 OK\r\n").await?; - self.stream.write(b"Content-Type: application/json\r\n").await?; - self.stream.write(b"X-Creddy-delaying-tactic: ").await?; - - #[allow(unreachable_code)] // seems necessary for type inference - let stall = async { - let delay = std::time::Duration::from_secs(1); - loop { - tokio::time::sleep(delay).await; - self.stream.write(b"x").await?; - } - Ok(Approval::Denied) - }; - - // this is the only place we even read this field, so it's safe to unwrap - let receiver = self.receiver.take().unwrap(); - tokio::select!{ - r = receiver => Ok(r.unwrap()), // only panics if the sender is dropped without sending, which shouldn't be possible - e = stall => e, - } - } - - async fn send_body(&mut self, body: &[u8]) -> Result<(), HandlerError> { - self.stream.write(b"\r\nContent-Length: ").await?; - self.stream.write(body.len().to_string().as_bytes()).await?; - self.stream.write(b"\r\n\r\n").await?; - self.stream.write(body).await?; - self.stream.shutdown().await?; - Ok(()) - } -} - - -#[derive(Debug)] -pub struct Server { - addr: Ipv4Addr, - port: u16, - app_handle: AppHandle, - task: JoinHandle<()>, -} - - -impl Server { - pub async fn new(addr: Ipv4Addr, port: u16, app_handle: AppHandle) -> io::Result { - let task = Self::start_server(addr, port, app_handle.app_handle()).await?; - Ok(Server { addr, port, app_handle, task}) - } - - pub async fn rebind(&mut self, addr: Ipv4Addr, port: u16) -> io::Result<()> { - if addr == self.addr && port == self.port { - return Ok(()) - } - - let new_task = Self::start_server(addr, port, self.app_handle.app_handle()).await?; - self.task.abort(); - - self.addr = addr; - self.port = port; - self.task = new_task; - Ok(()) - } - - // construct the listener before spawning the task so that we can return early if it fails - async fn start_server(addr: Ipv4Addr, port: u16, app_handle: AppHandle) -> io::Result> { - let sock_addr = SocketAddrV4::new(addr, port); - let listener = TcpListener::bind(&sock_addr).await?; - let task = rt::spawn( - Self::serve(listener, app_handle.app_handle()) - ); - Ok(task) - } - - async fn serve(listener: TcpListener, app_handle: AppHandle) { - loop { - match listener.accept().await { - Ok((stream, _)) => { - match Handler::new(stream, app_handle.app_handle()).await { - Ok(handler) => { rt::spawn(handler.handle()); } - Err(e) => { eprintln!("Error handling request: {e}"); } - } - }, - Err(e) => { eprintln!("Error accepting connection: {e}"); } - } - } - } -} diff --git a/src-tauri/src/server/mod.rs b/src-tauri/src/server/mod.rs new file mode 100644 index 0000000..e1f8463 --- /dev/null +++ b/src-tauri/src/server/mod.rs @@ -0,0 +1,126 @@ +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::oneshot; + +use serde::{Serialize, Deserialize}; + +use tauri::{AppHandle, Manager}; + +use crate::errors::*; +use crate::clientinfo::{self, Client}; +use crate::credentials::Credentials; +use crate::ipc::{Approval, AwsRequestNotification}; +use crate::state::AppState; +use crate::shortcuts::{self, ShortcutAction}; + +#[cfg(windows)] +mod server_win; +#[cfg(windows)] +pub use server_win::Server; +#[cfg(windows)] +use server_win::Stream; + +#[cfg(unix)] +mod server_unix; +#[cfg(unix)] +pub use server_unix::Server; +#[cfg(unix)] +use server_unix::Stream; + + +#[derive(Serialize, Deserialize)] +pub enum Request { + GetAwsCredentials{ + base: bool, + }, + InvokeShortcut(ShortcutAction), +} + + +#[derive(Debug, Serialize, Deserialize)] +pub enum Response { + Aws(Credentials), + Empty, +} + + +async fn handle(mut stream: Stream, app_handle: AppHandle, client_pid: u32) -> Result<(), HandlerError> +{ + // read from stream until delimiter is reached + let mut buf: Vec = Vec::with_capacity(1024); // requests are small, 1KiB is more than enough + let mut n = 0; + loop { + n += stream.read_buf(&mut buf).await?; + if let Some(&b'\n') = buf.last() { + break; + } + else if n >= 1024 { + return Err(HandlerError::RequestTooLarge); + } + } + + let client = clientinfo::get_process_parent_info(client_pid)?; + + let req: Request = serde_json::from_slice(&buf)?; + let res = match req { + Request::GetAwsCredentials{ base } => get_aws_credentials(base, client, app_handle).await, + Request::InvokeShortcut(action) => invoke_shortcut(action).await, + }; + + let res = serde_json::to_vec(&res).unwrap(); + stream.write_all(&res).await?; + Ok(()) +} + + +async fn invoke_shortcut(action: ShortcutAction) -> Result { + shortcuts::exec_shortcut(action); + Ok(Response::Empty) +} + + +async fn get_aws_credentials(base: bool, client: Client, app_handle: AppHandle) -> Result { + let state = app_handle.state::(); + let rehide_ms = { + let config = state.config.read().await; + config.rehide_ms + }; + let lease = state.acquire_visibility_lease(rehide_ms).await + .map_err(|_e| HandlerError::NoMainWindow)?; // automate this conversion eventually? + + let (chan_send, chan_recv) = oneshot::channel(); + let request_id = state.register_request(chan_send).await; + + // if an error occurs in any of the following, we want to abort the operation + // but ? returns immediately, and we want to unregister the request before returning + // so we bundle it all up in an async block and return a Result so we can handle errors + let proceed = async { + let notification = AwsRequestNotification {id: request_id, client, base}; + app_handle.emit_all("credentials-request", ¬ification)?; + + match chan_recv.await { + Ok(Approval::Approved) => { + if base { + let creds = state.base_creds_cloned().await?; + Ok(Response::Aws(Credentials::Base(creds))) + } + else { + let creds = state.session_creds_cloned().await?; + Ok(Response::Aws(Credentials::Session(creds))) + } + }, + Ok(Approval::Denied) => Err(HandlerError::Denied), + Err(_e) => Err(HandlerError::Internal), + } + }; + + let result = match proceed.await { + Ok(r) => Ok(r), + Err(e) => { + state.unregister_request(request_id).await; + Err(e) + } + }; + + lease.release(); + result +} diff --git a/src-tauri/src/server/server_unix.rs b/src-tauri/src/server/server_unix.rs new file mode 100644 index 0000000..bd3b4ff --- /dev/null +++ b/src-tauri/src/server/server_unix.rs @@ -0,0 +1,59 @@ +use std::io::ErrorKind; +use tokio::net::{UnixListener, UnixStream}; +use tauri::{ + AppHandle, + Manager, + async_runtime as rt, +}; + +use crate::errors::*; + + +pub type Stream = UnixStream; + + +pub struct Server { + listener: UnixListener, + app_handle: AppHandle, +} + +impl Server { + pub fn start(app_handle: AppHandle) -> std::io::Result<()> { + match std::fs::remove_file("/tmp/creddy.sock") { + Ok(_) => (), + Err(e) if e.kind() == ErrorKind::NotFound => (), + Err(e) => return Err(e), + } + + let listener = UnixListener::bind("/tmp/creddy.sock")?; + let srv = Server { listener, app_handle }; + rt::spawn(srv.serve()); + Ok(()) + } + + async fn serve(self) { + loop { + self.try_serve() + .await + .error_print_prefix("Error accepting request: "); + } + } + + async fn try_serve(&self) -> Result<(), HandlerError> { + let (stream, _addr) = self.listener.accept().await?; + let new_handle = self.app_handle.app_handle(); + let client_pid = get_client_pid(&stream)?; + rt::spawn(async move { + super::handle(stream, new_handle, client_pid) + .await + .error_print_prefix("Error responding to request: "); + }); + Ok(()) + } +} + + +fn get_client_pid(stream: &UnixStream) -> std::io::Result { + let cred = stream.peer_cred()?; + Ok(cred.pid().unwrap() as u32) +} diff --git a/src-tauri/src/server/server_win.rs b/src-tauri/src/server/server_win.rs new file mode 100644 index 0000000..07dd7ca --- /dev/null +++ b/src-tauri/src/server/server_win.rs @@ -0,0 +1,75 @@ +use tokio::{ + net::windows::named_pipe::{ + NamedPipeServer, + ServerOptions, + }, + sync::oneshot, +}; + +use windows::Win32:: { + Foundation::HANDLE, + System::Pipes::GetNamedPipeClientProcessId, +}; + +use std::os::windows::io::AsRawHandle; + +use tauri::async_runtime as rt; + +use crate::errors::*; + + +// used by parent module +pub type Stream = NamedPipeServer; + + +pub struct Server { + listener: NamedPipeServer, + app_handle: AppHandle, +} + +impl Server { + pub fn start(app_handle: AppHandle) -> std::io::Result<()> { + let listener = ServerOptions::new() + .first_pipe_instance(true) + .create(r"\\.\pipe\creddy-requests")?; + + let srv = Server {listener, app_handle}; + rt::spawn(srv.serve()); + Ok(()) + } + + async fn serve(mut self) { + loop { + if let Err(e) = self.try_serve().await { + eprintln!("Error accepting connection: {e}"); + } + } + } + + async fn try_serve(&mut self) -> Result<(), HandlerError> { + // connect() just waits for a client to connect, it doesn't return anything + self.listener.connect().await?; + + // create a new pipe instance to listen for the next client, and swap it in + let new_listener = ServerOptions::new().create(r"\\.\pipe\creddy-requests")?; + let mut stream = std::mem::replace(&mut self.listener, new_listener); + let new_handle = self.app_handle.app_handle(); + let client_pid = get_client_pid(&stream)?; + rt::spawn(async move { + super::handle(stream, app_handle) + .await + .error_print_prefix("Error responding to request: "); + }); + + Ok(()) + } +} + + +fn get_client_pid(pipe: &NamedPipeServer) -> Result { + let raw_handle = pipe.as_raw_handle(); + let mut pid = 0u32; + let handle = HANDLE(raw_handle as _); + unsafe { GetNamedPipeClientProcessId(handle, &mut pid as *mut u32)? }; + pid +} diff --git a/src-tauri/src/shortcuts.rs b/src-tauri/src/shortcuts.rs new file mode 100644 index 0000000..84abdc0 --- /dev/null +++ b/src-tauri/src/shortcuts.rs @@ -0,0 +1,60 @@ +use serde::{Serialize, Deserialize}; + +use tauri::{ + GlobalShortcutManager, + Manager, + async_runtime as rt, +}; + +use crate::app::APP; +use crate::config::HotkeysConfig; +use crate::errors::*; +use crate::terminal; + + +#[derive(Debug, Serialize, Deserialize)] +pub enum ShortcutAction { + ShowWindow, + LaunchTerminal, +} + + +pub fn exec_shortcut(action: ShortcutAction) { + match action { + ShortcutAction::ShowWindow => { + let app = APP.get().unwrap(); + app.get_window("main") + .ok_or("Couldn't find application main window") + .map(|w| w.show().error_popup("Failed to show window")) + .error_popup("Failed to show window"); + }, + ShortcutAction::LaunchTerminal => { + rt::spawn(async { + terminal::launch(false).await.error_popup("Failed to launch terminal"); + }); + }, + } +} + + +pub fn register_hotkeys(hotkeys: &HotkeysConfig) -> tauri::Result<()> { + let app = APP.get().unwrap(); + let mut manager = app.global_shortcut_manager(); + manager.unregister_all()?; + + if hotkeys.show_window.enabled { + manager.register( + &hotkeys.show_window.keys, + || exec_shortcut(ShortcutAction::ShowWindow) + )?; + } + + if hotkeys.launch_terminal.enabled { + manager.register( + &hotkeys.launch_terminal.keys, + || exec_shortcut(ShortcutAction::LaunchTerminal) + )?; + } + + Ok(()) +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index eda9a81..cd30af1 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,15 +1,16 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::time::Duration; use tokio::{ sync::RwLock, - time::sleep, + sync::oneshot::{self, Sender}, }; use sqlx::SqlitePool; -use tauri::async_runtime as runtime; -use tauri::Manager; +use tauri::{ + Manager, + async_runtime as rt, +}; -use crate::app::APP; use crate::credentials::{ Session, BaseCredentials, @@ -17,9 +18,74 @@ use crate::credentials::{ }; use crate::{config, config::AppConfig}; use crate::ipc::{self, Approval}; -use crate::clientinfo::Client; use crate::errors::*; -use crate::server::{Server, RequestWaiter}; +use crate::shortcuts; + + +#[derive(Debug)] +struct Visibility { + leases: usize, + original: Option, +} + +impl Visibility { + fn new() -> Self { + Visibility { leases: 0, original: None } + } + + fn acquire(&mut self, delay_ms: u64) -> Result { + let app = crate::app::APP.get().unwrap(); + let window = app.get_window("main") + .ok_or(WindowError::NoMainWindow)?; + + self.leases += 1; + if self.original.is_none() { + let is_visible = window.is_visible()?; + self.original = Some(is_visible); + if !is_visible { + window.show()?; + } + } + window.set_focus()?; + + let (tx, rx) = oneshot::channel(); + let lease = VisibilityLease { notify: tx }; + + let delay = Duration::from_millis(delay_ms); + let handle = app.app_handle(); + rt::spawn(async move { + // We don't care if it's an error; lease being dropped should be handled identically + let _ = rx.await; + tokio::time::sleep(delay).await; + // we can't use `self` here because we would have to move it into the async block + let state = handle.state::(); + let mut visibility = state.visibility.write().await; + visibility.leases -= 1; + if visibility.leases == 0 { + if let Some(false) = visibility.original { + window.hide().error_print(); + } + visibility.original = None; + } + }); + + Ok(lease) + } +} + +pub struct VisibilityLease { + notify: Sender<()>, +} + +impl VisibilityLease { + pub fn release(self) { + rt::spawn(async move { + if let Err(_) = self.notify.send(()) { + eprintln!("Error releasing visibility lease") + } + }); + } +} #[derive(Debug)] @@ -27,20 +93,18 @@ pub struct AppState { pub config: RwLock, pub session: RwLock, pub request_count: RwLock, - pub waiting_requests: RwLock>, + pub waiting_requests: RwLock>>, pub pending_terminal_request: RwLock, - pub bans: RwLock>>, // setup_errors is never modified and so doesn't need to be wrapped in RwLock pub setup_errors: Vec, - server: RwLock, pool: sqlx::SqlitePool, + visibility: RwLock, } impl AppState { pub fn new( config: AppConfig, session: Session, - server: Server, pool: SqlitePool, setup_errors: Vec, ) -> AppState { @@ -50,10 +114,9 @@ impl AppState { request_count: RwLock::new(0), waiting_requests: RwLock::new(HashMap::new()), pending_terminal_request: RwLock::new(false), - bans: RwLock::new(HashSet::new()), setup_errors, - server: RwLock::new(server), pool, + visibility: RwLock::new(Visibility::new()), } } @@ -73,18 +136,12 @@ impl AppState { if new_config.start_on_login != live_config.start_on_login { config::set_auto_launch(new_config.start_on_login)?; } - // rebind socket if necessary - if new_config.listen_addr != live_config.listen_addr - || new_config.listen_port != live_config.listen_port - { - let mut sv = self.server.write().await; - sv.rebind(new_config.listen_addr, new_config.listen_port).await?; - } + // re-register hotkeys if necessary if new_config.hotkeys.show_window != live_config.hotkeys.show_window || new_config.hotkeys.launch_terminal != live_config.hotkeys.launch_terminal { - config::register_hotkeys(&new_config.hotkeys)?; + shortcuts::register_hotkeys(&new_config.hotkeys)?; } new_config.save(&self.pool).await?; @@ -92,7 +149,7 @@ impl AppState { Ok(()) } - pub async fn register_request(&self, waiter: RequestWaiter) -> u64 { + pub async fn register_request(&self, sender: Sender) -> u64 { let count = { let mut c = self.request_count.write().await; *c += 1; @@ -100,7 +157,7 @@ impl AppState { }; let mut waiting_requests = self.waiting_requests.write().await; - waiting_requests.insert(*count, waiter); // `count` is the request id + waiting_requests.insert(*count, sender); // `count` is the request id *count } @@ -109,16 +166,9 @@ impl AppState { waiting_requests.remove(&id); } - pub async fn req_count(&self) -> usize { - let waiting_requests = self.waiting_requests.read().await; - waiting_requests.len() - } - - pub async fn current_rehide_status(&self) -> Option { - // since all requests that are pending at a given time should have the same - // value for rehide_after, it doesn't matter which one we use - let waiting_requests = self.waiting_requests.read().await; - waiting_requests.iter().next().map(|(_id, w)| w.rehide_after) + pub async fn acquire_visibility_lease(&self, delay: u64) -> Result { + let mut visibility = self.visibility.write().await; + visibility.acquire(delay) } pub async fn send_response(&self, response: ipc::RequestResponse) -> Result<(), SendResponseError> { @@ -129,26 +179,10 @@ impl AppState { let mut waiting_requests = self.waiting_requests.write().await; waiting_requests - .get_mut(&response.id) + .remove(&response.id) .ok_or(SendResponseError::NotFound)? - .notify(response.approval) - } - - pub async fn add_ban(&self, client: Option) { - let mut bans = self.bans.write().await; - bans.insert(client.clone()); - - runtime::spawn(async move { - sleep(Duration::from_secs(5)).await; - let app = APP.get().unwrap(); - let state = app.state::(); - let mut bans = state.bans.write().await; - bans.remove(&client); - }); - } - - pub async fn is_banned(&self, client: &Option) -> bool { - self.bans.read().await.contains(&client) + .send(response.approval) + .map_err(|_| SendResponseError::Abandoned) } pub async fn unlock(&self, passphrase: &str) -> Result<(), UnlockError> { @@ -168,16 +202,16 @@ impl AppState { matches!(*session, Session::Unlocked{..}) } - pub async fn serialize_base_creds(&self) -> Result { + pub async fn base_creds_cloned(&self) -> Result { let app_session = self.session.read().await; let (base, _session) = app_session.try_get()?; - Ok(serde_json::to_string(base).unwrap()) + Ok(base.clone()) } - pub async fn serialize_session_creds(&self) -> Result { + pub async fn session_creds_cloned(&self) -> Result { let app_session = self.session.read().await; let (_bsae, session) = app_session.try_get()?; - Ok(serde_json::to_string(session).unwrap()) + Ok(session.clone()) } async fn new_session(&self, base: BaseCredentials) -> Result<(), GetSessionError> { diff --git a/src-tauri/src/terminal.rs b/src-tauri/src/terminal.rs index fcac4db..4fb7f6d 100644 --- a/src-tauri/src/terminal.rs +++ b/src-tauri/src/terminal.rs @@ -26,13 +26,8 @@ pub async fn launch(use_base: bool) -> Result<(), LaunchTerminalError> { // if session is unlocked or empty, wait for credentials from frontend if !state.is_unlocked().await { app.emit_all("launch-terminal-request", ())?; - let window = app.get_window("main") - .ok_or(LaunchTerminalError::NoMainWindow)?; - if !window.is_visible()? { - window.unminimize()?; - window.show()?; - } - window.set_focus()?; + let lease = state.acquire_visibility_lease(0).await + .map_err(|_e| LaunchTerminalError::NoMainWindow)?; // automate conversion eventually? let (tx, rx) = tokio::sync::oneshot::channel(); app.once_global("credentials-event", move |e| { @@ -47,6 +42,7 @@ pub async fn launch(use_base: bool) -> Result<(), LaunchTerminalError> { state.unregister_terminal_request().await; return Ok(()); // request was canceled by user } + lease.release(); } // more lock-management @@ -63,7 +59,7 @@ pub async fn launch(use_base: bool) -> Result<(), LaunchTerminalError> { else { cmd.env("AWS_ACCESS_KEY_ID", &session_creds.access_key_id); cmd.env("AWS_SECRET_ACCESS_KEY", &session_creds.secret_access_key); - cmd.env("AWS_SESSION_TOKEN", &session_creds.token); + cmd.env("AWS_SESSION_TOKEN", &session_creds.session_token); } } diff --git a/src/ui/settings/NumericSetting.svelte b/src/ui/settings/NumericSetting.svelte index 4c8e4c2..b4a34cd 100644 --- a/src/ui/settings/NumericSetting.svelte +++ b/src/ui/settings/NumericSetting.svelte @@ -10,15 +10,21 @@ export let min = null; export let max = null; export let decimal = false; + export let debounceInterval = 0; const dispatch = createEventDispatcher(); $: localValue = value.toString(); let lastInputTime = null; function debounce(event) { - lastInputTime = Date.now(); localValue = localValue.replace(/[^-0-9.]/g, ''); + if (debounceInterval === 0) { + updateValue(localValue); + return; + } + + lastInputTime = Date.now(); const eventTime = lastInputTime; const pendingValue = localValue; window.setTimeout( @@ -28,7 +34,7 @@ updateValue(pendingValue); } }, - 500 + debounceInterval, ) } diff --git a/src/views/Approve.svelte b/src/views/Approve.svelte index d07e874..6ccc86c 100644 --- a/src/views/Approve.svelte +++ b/src/views/Approve.svelte @@ -47,16 +47,13 @@ } // Extract executable name from full path - let appName = null; - if ($appState.currentRequest.clients.length === 1) { - let path = $appState.currentRequest.clients[0].exe; - let m = path.match(/\/([^/]+?$)|\\([^\\]+?$)/); - appName = m[1] || m[2]; - } + const client = $appState.currentRequest.client; + const m = client.exe?.match(/\/([^/]+?$)|\\([^\\]+?$)/); + const appName = m[1] || m[2]; // Executable paths can be long, so ensure they only break on \ or / - function breakPath(client) { - return client.exe.replace(/(\\|\/)/g, '$1'); + function breakPath(path) { + return path.replace(/(\\|\/)/g, '$1'); } // if the request has already been approved/denied, send response immediately @@ -97,12 +94,10 @@

{appName ? `"${appName}"` : 'An appplication'} would like to access your AWS credentials.

- {#each $appState.currentRequest.clients as client} -
Path:
- {@html client ? breakPath(client) : 'Unknown'} -
PID:
- {client ? client.pid : 'Unknown'} - {/each} +
Path:
+ {@html client.exe ? breakPath(client.exe) : 'Unknown'} +
PID:
+ {client.pid}
diff --git a/src/views/EnterCredentials.svelte b/src/views/EnterCredentials.svelte index 2570a8b..4a61004 100644 --- a/src/views/EnterCredentials.svelte +++ b/src/views/EnterCredentials.svelte @@ -1,6 +1,7 @@