21 Commits

Author SHA1 Message Date
141334f7e2 add idle timeout and version on settings screen 2024-01-31 13:14:08 -08:00
69f6a39396 change tray menu text when toggling visibility 2024-01-26 21:03:45 -08:00
70e23c7e20 add version to BaseCredentials 2024-01-23 10:58:39 -08:00
1df849442e cancel approval flow on frontend when request is abandoned by client 2024-01-21 13:46:39 -08:00
7fdb336c79 rework approval buttons and add hotkey for base approval 2024-01-20 11:06:27 -08:00
46b8d810c5 allow user to choose whether to send base credentials at approval screen 2024-01-10 17:10:14 -08:00
dd40eb379e update dependencies 2024-01-10 16:31:10 -08:00
13545ac725 v0.4.1 2023-11-09 14:25:20 -08:00
040a01536a work around Gnome focus-stealing prevention 2023-11-09 14:24:44 -08:00
4e2a90b15b remove old client info code 2023-11-09 13:46:15 -08:00
e0d919ed4a fix Windows pipe server 2023-10-09 16:29:41 -07:00
3f4efc5f8f bump version to 0.4.0 2023-10-09 10:06:28 -07:00
4881b90b0b merge branch 'pipe' 2023-10-09 08:54:26 -07:00
1b749a857c disable hotkeys if initial registration fails 2023-10-09 08:50:31 -07:00
2079f99d04 bump version to 0.3.4 2023-10-08 22:53:22 -07:00
5e0ffc1155 use save dialog for settings instead of autosaving 2023-10-08 22:06:30 -07:00
d4fa8966b2 add unix listener, split win/unix into separate submodules 2023-09-23 11:10:54 -07:00
a293d8f92c ignore .env so it can be system-specific 2023-09-22 12:43:44 -07:00
4b06dce7f4 keep working on cli shortcuts, unify visibility management 2023-09-21 10:44:35 -07:00
47a3e1cfef start work on invoking shortcuts from CLI 2023-09-18 20:13:56 -07:00
1047818fdc basic implementation of named-pipe server 2023-09-18 20:13:29 -07:00
38 changed files with 1698 additions and 2069 deletions

3
.gitignore vendored
View File

@ -2,6 +2,9 @@ dist
**/node_modules
src-tauri/target/
**/creddy.db
# .env is system-specific
.env
.vscode
# just in case
credentials*

View File

@ -1,13 +1,17 @@
## Definitely
* Switch to "process" provider for AWS credentials (much less hacky)
* ~~Switch to "process" provider for AWS credentials (much less hacky)~~
* ~~Frontend needs to react when request is cancelled from backend~~
* Session timeout (plain duration, or activity-based?)
* ~Fix rehide behavior when new request comes in while old one is still being resolved~
* Indicate on approval screen when additional requests are pending
* ~~Fix rehide behavior when new request comes in while old one is still being resolved~~
* Additional hotkey configuration (approve/deny at the very least)
* ~~Switch tray menu item to Hide when window is visible~~
* Logging
* Icon
* Auto-updates
* SSH key handling
* Encrypted sync server
## Maybe
@ -16,3 +20,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

1854
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "creddy",
"version": "0.3.4",
"version": "0.4.5",
"scripts": {
"dev": "vite",
"build": "vite build",

View File

@ -1 +0,0 @@
DATABASE_URL=sqlite://C:/Users/Joe/AppData/Roaming/creddy/creddy.dev.db

73
src-tauri/Cargo.lock generated
View File

@ -1035,7 +1035,7 @@ dependencies = [
[[package]]
name = "creddy"
version = "0.3.4"
version = "0.4.6"
dependencies = [
"argon2",
"auto-launch",
@ -1047,7 +1047,6 @@ dependencies = [
"clap",
"dirs 5.0.1",
"is-terminal",
"netstat2",
"once_cell",
"serde",
"serde_json",
@ -1060,8 +1059,10 @@ dependencies = [
"tauri-build",
"tauri-plugin-single-instance",
"thiserror",
"time",
"tokio",
"which",
"windows 0.51.1",
]
[[package]]
@ -1202,10 +1203,11 @@ dependencies = [
[[package]]
name = "deranged"
version = "0.3.8"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2696e8a945f658fd14dc3b87242e6b80cd0f36ff04ea560fa39082368847946"
checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
dependencies = [
"powerfmt",
"serde",
]
@ -2641,20 +2643,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 +2696,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"
@ -3183,6 +3160,12 @@ dependencies = [
"universal-hash",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.17"
@ -4561,12 +4544,13 @@ dependencies = [
[[package]]
name = "time"
version = "0.3.28"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17f6bb557fd245c28e6411aa56b6403c689ad95061f50e4be16c274e70a17e48"
checksum = "f657ba42c3f86e7680e53c8cd3af8abbe56b5491790b46e22e19c0d57463583e"
dependencies = [
"deranged",
"itoa 1.0.9",
"powerfmt",
"serde",
"time-core",
"time-macros",
@ -4574,15 +4558,15 @@ dependencies = [
[[package]]
name = "time-core"
version = "0.1.1"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7300fbefb4dadc1af235a9cef3737cea692a9d97e1b9cbcd4ebdae6f8868e6fb"
checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
[[package]]
name = "time-macros"
version = "0.2.14"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a942f44339478ef67935ab2bbaec2fb0322496cf3cbe84b261e06ac3814c572"
checksum = "26197e33420244aeb70c3e8c78376ca46571bc4e701e4791c2cd9f57dcb3a43f"
dependencies = [
"time-core",
]
@ -5261,6 +5245,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 +5265,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"

View File

@ -1,6 +1,6 @@
[package]
name = "creddy"
version = "0.3.4"
version = "0.4.6"
description = "A friendly AWS credentials manager"
authors = ["Joseph Montanaro"]
license = ""
@ -25,12 +25,11 @@ tauri-build = { version = "1.0.4", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.2", features = ["dialog", "dialog-open", "global-shortcut", "os-all", "system-tray"] }
tauri = { version = "1.2", features = [ "app-all", "dialog", "dialog-open", "global-shortcut", "os-all", "system-tray"] }
tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" }
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,8 @@ 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"] }
time = "0.3.31"
[features]
# by default Tauri runs in production mode

View File

@ -1,4 +1,5 @@
use std::error::Error;
use std::time::Duration;
use once_cell::sync::OnceCell;
use sqlx::{
@ -19,6 +20,7 @@ use crate::{
ipc,
server::Server,
errors::*,
shortcuts,
state::AppState,
tray,
};
@ -30,8 +32,8 @@ pub static APP: OnceCell<AppHandle> = OnceCell::new();
pub fn run() -> tauri::Result<()> {
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
app.get_window("main")
.map(|w| w.show().error_popup("Failed to show main window"));
show_main_window(app)
.error_popup("Failed to show main window");
}))
.system_tray(tray::create())
.on_system_tray_event(tray::handle_event)
@ -39,6 +41,7 @@ pub fn run() -> tauri::Result<()> {
ipc::unlock,
ipc::respond,
ipc::get_session_status,
ipc::signal_activity,
ipc::save_credentials,
ipc::get_config,
ipc::save_config,
@ -48,9 +51,9 @@ pub fn run() -> tauri::Result<()> {
.setup(|app| rt::block_on(setup(app)))
.build(tauri::generate_context!())?
.run(|app, run_event| match run_event {
tauri::RunEvent::WindowEvent { label, event, .. } => match event {
tauri::RunEvent::WindowEvent { event, .. } => match event {
tauri::WindowEvent::CloseRequested { api, .. } => {
let _ = app.get_window(&label).map(|w| w.hide());
let _ = hide_main_window(app);
api.prevent_close();
}
_ => ()
@ -93,26 +96,81 @@ async fn setup(app: &mut App) -> Result<(), Box<dyn Error>> {
};
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());
}
let desktop_is_gnome = std::env::var("XDG_CURRENT_DESKTOP")
.map(|names| names.split(':').any(|n| n == "GNOME"))
.unwrap_or(false);
// if session is empty, this is probably the first launch, so don't autohide
if !conf.start_minimized || is_first_launch {
app.get_window("main")
.ok_or(HandlerError::NoMainWindow)?
.show()?;
show_main_window(&app.handle())?;
}
let state = AppState::new(conf, session, srv, pool, setup_errors);
let state = AppState::new(conf, session, pool, setup_errors, desktop_is_gnome);
app.manage(state);
// make sure we do this after managing app state, so that it doesn't panic
start_auto_locker(app.app_handle());
Ok(())
}
fn start_auto_locker(app: AppHandle) {
rt::spawn(async move {
let state = app.state::<AppState>();
loop {
// this gives our session-timeout a minimum resolution of 10s, which seems fine?
let delay = Duration::from_secs(10);
tokio::time::sleep(delay).await;
if state.should_auto_lock().await {
state.lock().await.error_popup("Failed to lock Creddy");
}
}
});
}
pub fn show_main_window(app: &AppHandle) -> Result<(), WindowError> {
let w = app.get_window("main").ok_or(WindowError::NoMainWindow)?;
w.show()?;
app.tray_handle()
.get_item("show_hide")
.set_title("Hide")?;
Ok(())
}
pub fn hide_main_window(app: &AppHandle) -> Result<(), WindowError> {
let w = app.get_window("main").ok_or(WindowError::NoMainWindow)?;
w.hide()?;
app.tray_handle()
.get_item("show_hide")
.set_title("Show")?;
Ok(())
}
pub fn toggle_main_window(app: &AppHandle) -> Result<(), WindowError> {
let w = app.get_window("main").ok_or(WindowError::NoMainWindow)?;
if w.is_visible()? {
hide_main_window(app)
}
else {
show_main_window(app)
}
}

View File

@ -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);
}
}

View File

@ -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<String, RequestError> {
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::<String>("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<Credentials, RequestError> {
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<Response, RequestError> {
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<Response, ServerError> = serde_json::from_slice(&buf)?;
Ok(res?)
}
#[cfg(windows)]
async fn connect() -> Result<NamedPipeClient, std::io::Error> {
// 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, std::io::Error> {
UnixStream::connect("/tmp/creddy.sock").await
}

View File

@ -1,76 +1,35 @@
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<PathBuf>,
}
async fn get_associated_pids(local_port: u16) -> Result<Vec<u32>, netstat2::error::Error> {
let state = APP.get().unwrap().state::<AppState>();
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<Client, ClientInfoError> {
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![])
}
// Theoretically, on some systems, multiple processes can share a socket
pub async fn get_clients(local_port: u16) -> Result<Vec<Option<Client>>, 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));
}
if clients.is_empty() {
clients.push(None);
}
Ok(clients)
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 })
}

View File

@ -1,15 +1,10 @@
use std::net::Ipv4Addr;
use std::path::PathBuf;
use std::time::Duration;
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,15 +34,22 @@ 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_auto_lock")]
pub auto_lock: bool,
#[serde(default = "default_lock_after")]
pub lock_after: Duration,
#[serde(default = "default_start_minimized")]
pub start_minimized: bool,
#[serde(default = "default_start_on_login")]
@ -62,9 +64,9 @@ 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(),
auto_lock: default_auto_lock(),
lock_after: default_lock_after(),
start_minimized: default_start_minimized(),
start_on_login: default_start_on_login(),
terminal: default_term_config(),
@ -144,16 +146,6 @@ pub fn get_or_create_db_path() -> Result<PathBuf, DataDirError> {
}
fn default_listen_port() -> u16 {
if cfg!(debug_assertions) {
12_345
}
else {
19_923
}
}
fn default_term_config() -> TermConfig {
#[cfg(windows)]
{
@ -200,53 +192,32 @@ 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 }
fn default_auto_lock() -> bool { true }
fn default_lock_after() -> Duration { Duration::from_secs(43200) }
// start minimized and on login only in production mode
fn default_start_minimized() -> bool { !cfg!(debug_assertions) }
fn default_start_on_login() -> bool { !cfg!(debug_assertions) }
// struct DurationVisitor;
// impl<'de> Visitor<'de> for DurationVisitor {
// type Value = Duration;
// fn expecting(&self, formatter: &mut Formatter) -> fmt::Result {
// write!(formatter, "an integer between 0 and 2^64 - 1")
// }
// fn visit_u64<E: de::Error>(self, v: u64) -> Result<Duration, E> {
// Ok(Duration::from_secs(v))
// }
// }
// fn duration_from_secs<'de, D>(deserializer: D) -> Result<Duration, D::Error>
// where D: Deserializer<'de>
// {
// deserializer.deserialize_u64(DurationVisitor)
// }

View File

@ -126,10 +126,10 @@ impl LockedCredentials {
let secret_access_key = String::from_utf8(decrypted)
.map_err(|_| UnlockError::InvalidUtf8)?;
let creds = BaseCredentials {
access_key_id: self.access_key_id.clone(),
let creds = BaseCredentials::new(
self.access_key_id.clone(),
secret_access_key,
};
);
Ok(creds)
}
}
@ -138,11 +138,16 @@ impl LockedCredentials {
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct BaseCredentials {
pub version: usize,
pub access_key_id: String,
pub secret_access_key: String,
}
impl BaseCredentials {
pub fn new(access_key_id: String, secret_access_key: String) -> Self {
Self {version: 1, access_key_id, secret_access_key}
}
pub fn encrypt(&self, passphrase: &str) -> Result<LockedCredentials, CryptoError> {
let salt = Crypto::salt();
let crypto = Crypto::new(passphrase, &salt)?;
@ -162,9 +167,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 +204,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 +212,10 @@ impl SessionCredentials {
.clone();
let session_creds = SessionCredentials {
version: 1,
access_key_id,
secret_access_key,
token,
session_token,
expiration,
};
@ -230,6 +237,14 @@ impl SessionCredentials {
}
}
#[derive(Debug, Serialize, Deserialize)]
pub enum Credentials {
Base(BaseCredentials),
Session(SessionCredentials),
}
fn serialize_expiration<S>(exp: &DateTime, serializer: S) -> Result<S::Ok, S::Error>
where S: Serializer
{

View File

@ -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,23 @@ use tauri::api::dialog::{
MessageDialogBuilder,
MessageDialogKind,
};
use serde::{Serialize, Serializer, ser::SerializeMap};
use tokio::sync::oneshot::error::RecvError;
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<E: std::fmt::Display> ErrorPopup for Result<(), E> {
impl<E: std::fmt::Display> ShowError for Result<(), E> {
fn error_popup(self, title: &str) {
if let Err(e) = self {
let (tx, rx) = mpsc::channel();
@ -44,6 +53,18 @@ impl<E: std::fmt::Display> 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}");
}
}
}
@ -62,15 +83,33 @@ where
}
struct SerializeUpstream<E>(pub E);
impl<E: Error> Serialize for SerializeUpstream<E> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let msg = format!("{}", self.0);
let mut map = serializer.serialize_map(None)?;
map.serialize_entry("msg", &msg)?;
map.serialize_entry("code", &None::<&str>)?;
map.serialize_entry("source", &None::<&str>)?;
map.end()
}
}
fn serialize_upstream_err<E, M>(err: &E, map: &mut M) -> Result<(), M::Error>
where
E: Error,
M: serde::ser::SerializeMap,
{
let msg = err.source().map(|s| format!("{s}"));
map.serialize_entry("msg", &msg)?;
map.serialize_entry("code", &None::<&str>)?;
map.serialize_entry("source", &None::<&str>)?;
// let msg = err.source().map(|s| format!("{s}"));
// map.serialize_entry("msg", &msg)?;
// map.serialize_entry("code", &None::<&str>)?;
// map.serialize_entry("source", &None::<&str>)?;
match err.source() {
Some(src) => map.serialize_entry("source", &SerializeUpstream(src))?,
None => map.serialize_entry("source", &None::<&str>)?,
}
Ok(())
}
@ -132,17 +171,21 @@ pub enum SendResponseError {
}
// errors encountered while handling an HTTP request
// errors encountered while handling a client request
#[derive(Debug, ThisError, AsRefStr)]
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<u8>),
BadRequest(#[from] serde_json::Error),
#[error("HTTP request too large")]
RequestTooLarge,
#[error("Connection closed early by client")]
Abandoned,
#[error("Internal server error")]
Internal(#[from] RecvError),
#[error("Error accessing credentials: {0}")]
NoCredentials(#[from] GetCredentialsError),
#[error("Error getting client details: {0}")]
@ -151,6 +194,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),
}
@ -193,6 +247,19 @@ pub enum UnlockError {
}
#[derive(Debug, ThisError, AsRefStr)]
pub enum LockError {
#[error("App is not unlocked")]
NotUnlocked,
#[error("Database error: {0}")]
DbError(#[from] SqlxError),
#[error(transparent)]
Setup(#[from] SetupError),
#[error(transparent)]
TauriError(#[from] tauri::Error),
}
#[derive(Debug, ThisError, AsRefStr)]
pub enum CryptoError {
#[error(transparent)]
@ -207,26 +274,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<ServerError> for RequestError {
fn from(s: ServerError) -> Self {
Self::Server(s)
}
}
@ -287,10 +378,11 @@ impl Serialize for SerializeWrapper<&GetSessionTokenError> {
}
impl_serialize_basic!(SetupError);
impl_serialize_basic!(GetCredentialsError);
impl_serialize_basic!(ClientInfoError);
impl_serialize_basic!(WindowError);
impl_serialize_basic!(LockError);
impl Serialize for HandlerError {
@ -298,13 +390,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 +438,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()

View File

@ -10,9 +10,9 @@ use crate::terminal;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Request {
pub struct AwsRequestNotification {
pub id: u64,
pub clients: Vec<Option<Client>>,
pub client: Client,
pub base: bool,
}
@ -21,6 +21,7 @@ pub struct Request {
pub struct RequestResponse {
pub id: u64,
pub approval: Approval,
pub base: bool,
}
@ -55,6 +56,13 @@ pub async fn get_session_status(app_state: State<'_, AppState>) -> Result<String
}
#[tauri::command]
pub async fn signal_activity(app_state: State<'_, AppState>) -> Result<(), ()> {
app_state.signal_activity().await;
Ok(())
}
#[tauri::command]
pub async fn save_credentials(
credentials: BaseCredentials,

View File

@ -7,5 +7,6 @@ mod clientinfo;
mod ipc;
mod state;
mod server;
mod shortcuts;
mod terminal;
mod tray;

View File

@ -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);
}
}

View File

@ -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<Sender<Approval>>,
}
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<Receiver<Approval>>,
app: AppHandle,
}
impl Handler {
async fn new(stream: TcpStream, app: AppHandle) -> Result<Self, HandlerError> {
let state = app.state::<AppState>();
// 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::<AppState>();
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::<AppState>();
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::<AppState>();
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::<AppState>();
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<Vec<u8>, 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<Vec<Option<Client>>, 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<Option<Client>>) -> bool {
let state = self.app.state::<AppState>();
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<Approval, HandlerError> {
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<Server> {
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<JoinHandle<()>> {
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}"); }
}
}
}
}

164
src-tauri/src/server/mod.rs Normal file
View File

@ -0,0 +1,164 @@
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,
}
struct CloseWaiter<'s> {
stream: &'s mut Stream,
}
impl<'s> CloseWaiter<'s> {
async fn wait_for_close(&mut self) -> std::io::Result<()> {
let mut buf = [0u8; 8];
loop {
match self.stream.read(&mut buf).await {
Ok(0) => break Ok(()),
Ok(_) => (),
Err(e) => break Err(e),
}
}
}
}
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<u8> = 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 waiter = CloseWaiter { stream: &mut stream };
let req: Request = serde_json::from_slice(&buf)?;
let res = match req {
Request::GetAwsCredentials{ base } => get_aws_credentials(
base, client, app_handle, waiter
).await,
Request::InvokeShortcut(action) => invoke_shortcut(action).await,
};
// doesn't make sense to send the error to the client if the client has already left
if let Err(HandlerError::Abandoned) = res {
return Err(HandlerError::Abandoned);
}
let res = serde_json::to_vec(&res).unwrap();
stream.write_all(&res).await?;
Ok(())
}
async fn invoke_shortcut(action: ShortcutAction) -> Result<Response, HandlerError> {
shortcuts::exec_shortcut(action);
Ok(Response::Empty)
}
async fn get_aws_credentials(
base: bool,
client: Client,
app_handle: AppHandle,
mut waiter: CloseWaiter<'_>,
) -> Result<Response, HandlerError> {
let state = app_handle.state::<AppState>();
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", &notification)?;
let response = tokio::select! {
r = chan_recv => r?,
_ = waiter.wait_for_close() => {
app_handle.emit_all("request-cancelled", request_id)?;
return Err(HandlerError::Abandoned);
},
};
match response.approval {
Approval::Approved => {
if response.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)))
}
},
Approval::Denied => Err(HandlerError::Denied),
}
};
let result = match proceed.await {
Ok(r) => Ok(r),
Err(e) => {
state.unregister_request(request_id).await;
Err(e)
}
};
lease.release();
result
}

View File

@ -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<u32> {
let cred = stream.peer_cred()?;
Ok(cred.pid().unwrap() as u32)
}

View File

@ -0,0 +1,74 @@
use tokio::net::windows::named_pipe::{
NamedPipeServer,
ServerOptions,
};
use tauri::{AppHandle, Manager};
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 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, new_handle, client_pid)
.await
.error_print_prefix("Error responding to request: ");
});
Ok(())
}
}
fn get_client_pid(pipe: &NamedPipeServer) -> Result<u32, ClientInfoError> {
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)? };
Ok(pid)
}

View File

@ -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(())
}

View File

@ -1,59 +1,138 @@
use std::collections::{HashMap, HashSet};
use std::collections::HashMap;
use std::time::Duration;
use time::OffsetDateTime;
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::app;
use crate::credentials::{
Session,
BaseCredentials,
SessionCredentials,
};
use crate::{config, config::AppConfig};
use crate::ipc::{self, Approval};
use crate::clientinfo::Client;
use crate::ipc::{self, Approval, RequestResponse};
use crate::errors::*;
use crate::server::{Server, RequestWaiter};
use crate::shortcuts;
#[derive(Debug)]
struct Visibility {
leases: usize,
original: Option<bool>,
}
impl Visibility {
fn new() -> Self {
Visibility { leases: 0, original: None }
}
fn acquire(&mut self, delay_ms: u64) -> Result<VisibilityLease, WindowError> {
let app = crate::app::APP.get().unwrap();
let window = app.get_window("main")
.ok_or(WindowError::NoMainWindow)?;
self.leases += 1;
// `original` represents the visibility of the window before any leases were acquired
// None means we don't know, Some(false) means it was previously hidden,
// Some(true) means it was previously visible
let is_visible = window.is_visible()?;
if self.original.is_none() {
self.original = Some(is_visible);
}
let state = app.state::<AppState>();
if is_visible && state.desktop_is_gnome {
// Gnome has a really annoying "focus-stealing prevention" behavior means we
// can't just pop up when the window is already visible, so to work around it
// we hide and then immediately unhide the window
window.hide()?;
}
app::show_main_window(&app)?;
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::<AppState>();
let mut visibility = state.visibility.write().await;
visibility.leases -= 1;
if visibility.leases == 0 {
if let Some(false) = visibility.original {
app::hide_main_window(&handle).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)]
pub struct AppState {
pub config: RwLock<AppConfig>,
pub session: RwLock<Session>,
pub last_activity: RwLock<OffsetDateTime>,
pub request_count: RwLock<u64>,
pub waiting_requests: RwLock<HashMap<u64, RequestWaiter>>,
pub waiting_requests: RwLock<HashMap<u64, Sender<RequestResponse>>>,
pub pending_terminal_request: RwLock<bool>,
pub bans: RwLock<std::collections::HashSet<Option<Client>>>,
// setup_errors is never modified and so doesn't need to be wrapped in RwLock
// these are never modified and so don't need to be wrapped in RwLocks
pub setup_errors: Vec<String>,
server: RwLock<Server>,
pub desktop_is_gnome: bool,
pool: sqlx::SqlitePool,
visibility: RwLock<Visibility>,
}
impl AppState {
pub fn new(
config: AppConfig,
session: Session,
server: Server,
pool: SqlitePool,
setup_errors: Vec<String>,
desktop_is_gnome: bool,
) -> AppState {
AppState {
config: RwLock::new(config),
session: RwLock::new(session),
last_activity: RwLock::new(OffsetDateTime::now_utc()),
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),
desktop_is_gnome,
pool,
visibility: RwLock::new(Visibility::new()),
}
}
@ -73,18 +152,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 +165,7 @@ impl AppState {
Ok(())
}
pub async fn register_request(&self, waiter: RequestWaiter) -> u64 {
pub async fn register_request(&self, sender: Sender<RequestResponse>) -> u64 {
let count = {
let mut c = self.request_count.write().await;
*c += 1;
@ -100,7 +173,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 +182,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<bool> {
// 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<VisibilityLease, WindowError> {
let mut visibility = self.visibility.write().await;
visibility.acquire(delay)
}
pub async fn send_response(&self, response: ipc::RequestResponse) -> Result<(), SendResponseError> {
@ -129,26 +195,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<Client>) {
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::<AppState>();
let mut bans = state.bans.write().await;
bans.remove(&client);
});
}
pub async fn is_banned(&self, client: &Option<Client>) -> bool {
self.bans.read().await.contains(&client)
.send(response)
.map_err(|_| SendResponseError::Abandoned)
}
pub async fn unlock(&self, passphrase: &str) -> Result<(), UnlockError> {
@ -163,21 +213,53 @@ impl AppState {
Ok(())
}
pub async fn lock(&self) -> Result<(), LockError> {
let mut session = self.session.write().await;
match *session {
Session::Empty => Err(LockError::NotUnlocked),
Session::Locked(_) => Err(LockError::NotUnlocked),
Session::Unlocked{..} => {
*session = Session::load(&self.pool).await?;
let app_handle = app::APP.get().unwrap();
app_handle.emit_all("locked", None::<usize>)?;
Ok(())
}
}
}
pub async fn signal_activity(&self) {
let mut last_activity = self.last_activity.write().await;
*last_activity = OffsetDateTime::now_utc();
}
pub async fn should_auto_lock(&self) -> bool {
let config = self.config.read().await;
if !config.auto_lock || !self.is_unlocked().await {
return false;
}
let last_activity = self.last_activity.read().await;
let elapsed = OffsetDateTime::now_utc() - *last_activity;
elapsed >= config.lock_after
}
pub async fn is_unlocked(&self) -> bool {
let session = self.session.read().await;
matches!(*session, Session::Unlocked{..})
}
pub async fn serialize_base_creds(&self) -> Result<String, GetCredentialsError> {
pub async fn base_creds_cloned(&self) -> Result<BaseCredentials, GetCredentialsError> {
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<String, GetCredentialsError> {
pub async fn session_creds_cloned(&self) -> Result<SessionCredentials, GetCredentialsError> {
let app_session = self.session.read().await;
let (_bsae, session) = app_session.try_get()?;
Ok(serde_json::to_string(session).unwrap())
let (_base, session) = app_session.try_get()?;
Ok(session.clone())
}
async fn new_session(&self, base: BaseCredentials) -> Result<(), GetSessionError> {

View File

@ -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);
}
}

View File

@ -1,15 +1,19 @@
use tauri::{
AppHandle,
CustomMenuItem,
Manager,
SystemTray,
SystemTrayEvent,
SystemTrayMenu,
CustomMenuItem,
async_runtime as rt,
};
use crate::app;
use crate::state::AppState;
pub fn create() -> SystemTray {
let show = CustomMenuItem::new("show".to_string(), "Show");
let show = CustomMenuItem::new("show_hide".to_string(), "Show");
let quit = CustomMenuItem::new("exit".to_string(), "Exit");
let menu = SystemTrayMenu::new()
@ -20,13 +24,18 @@ pub fn create() -> SystemTray {
}
pub fn handle_event(app: &AppHandle, event: SystemTrayEvent) {
pub fn handle_event(app_handle: &AppHandle, event: SystemTrayEvent) {
match event {
SystemTrayEvent::MenuItemClick{ id, .. } => {
match id.as_str() {
"exit" => app.exit(0),
"show" => {
let _ = app.get_window("main").map(|w| w.show());
"exit" => app_handle.exit(0),
"show_hide" => {
let _ = app::toggle_main_window(app_handle);
let new_handle = app_handle.app_handle();
rt::spawn(async move {
let state = new_handle.state::<AppState>();
state.signal_activity().await;
});
}
_ => (),
}

View File

@ -8,10 +8,15 @@
},
"package": {
"productName": "creddy",
"version": "0.3.4"
"version": "0.4.6"
},
"tauri": {
"allowlist": {
"app": {
"all": true,
"show": false,
"hide": false
},
"os": {"all": true},
"dialog": {"open": true}
},

View File

@ -3,7 +3,7 @@ import { onMount } from 'svelte';
import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/tauri';
import { appState, acceptRequest } from './lib/state.js';
import { appState, acceptRequest, cleanupRequest } from './lib/state.js';
import { views, currentView, navigate } from './lib/routing.js';
@ -16,6 +16,16 @@ listen('credentials-request', (tauriEvent) => {
$appState.pendingRequests.put(tauriEvent.payload);
});
listen('request-cancelled', (tauriEvent) => {
const id = tauriEvent.payload;
if (id === $appState.currentRequest?.id) {
cleanupRequest()
}
else {
const found = $appState.pendingRequests.find_remove(r => r.id === id);
}
});
listen('launch-terminal-request', async (tauriEvent) => {
if ($appState.currentRequest === null) {
let status = await invoke('get_session_status');

View File

@ -30,5 +30,15 @@ export default function() {
return this.items.shift();
},
find_remove(pred) {
for (let i=0; i<this.items.length; i++) {
if (pred(this.items[i])) {
this.items.splice(i, 1);
return true;
}
}
return false;
},
}
}

View File

@ -23,7 +23,7 @@ export async function acceptRequest() {
}
export function completeRequest() {
export function cleanupRequest() {
appState.update($appState => {
$appState.currentRequest = null;
return $appState;

View File

@ -5,3 +5,8 @@
.btn-alert-error {
@apply bg-transparent hover:bg-[#cd5a5a] border border-error-content text-error-content
}
/* I like alert icons to be top-aligned */
.alert > :where(*) {
align-items: flex-start;
}

View File

@ -1,13 +1,15 @@
<script>
export let keys;
let classes;
export {classes as class};
</script>
<div class="flex gap-x-[0.2em] items-center">
<span class="inline-flex gap-x-[0.2em] items-center {classes}">
{#each keys as key, i}
{#if i > 0}
<span class="mt-[-0.1em]">+</span>
{/if}
<kbd class="normal-case px-1 py-0.5 rounded border border-neutral">{key}</kbd>
{/each}
</div>
</span>

View File

@ -21,15 +21,15 @@
throw(`Link target is not a string or a function: ${target}`)
}
}
function handleHotkey(event) {
if (!hotkey) return;
if (ctrl && !event.ctrlKey) return;
if (alt && !event.altKey) return;
if (shift && !event.shiftKey) return;
if (event.key === hotkey) {
if (
hotkey === event.key
&& ctrl === event.ctrlKey
&& alt === event.altKey
&& shift === event.shiftKey
) {
click();
}
}

View File

@ -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,
)
}

View File

@ -3,7 +3,7 @@
import { invoke } from '@tauri-apps/api/tauri';
import { navigate } from '../lib/routing.js';
import { appState, completeRequest } from '../lib/state.js';
import { appState, cleanupRequest } from '../lib/state.js';
import ErrorAlert from '../ui/ErrorAlert.svelte';
import Link from '../ui/Link.svelte';
import KeyCombo from '../ui/KeyCombo.svelte';
@ -12,9 +12,12 @@
// Send response to backend, display error if applicable
let error, alert;
async function respond() {
let {id, approval} = $appState.currentRequest;
const response = {
id: $appState.currentRequest.id,
...$appState.currentRequest.response,
};
try {
await invoke('respond', {response: {id, approval}});
await invoke('respond', {response});
navigate('ShowResponse');
}
catch (e) {
@ -26,8 +29,8 @@
}
// Approval has one of several outcomes depending on current credential state
async function approve() {
$appState.currentRequest.approval = 'Approved';
async function approve(base) {
$appState.currentRequest.response = {approval: 'Approved', base};
let status = await invoke('get_session_status');
if (status === 'unlocked') {
await respond();
@ -40,53 +43,57 @@
}
}
function approve_base() {
approve(true);
}
function approve_session() {
approve(false);
}
// Denial has only one
async function deny() {
$appState.currentRequest.approval = 'Denied';
$appState.currentRequest.response = {approval: 'Denied', base: false};
await respond();
}
// 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<wbr>');
function breakPath(path) {
return path.replace(/(\\|\/)/g, '$1<wbr>');
}
// if the request has already been approved/denied, send response immediately
onMount(async () => {
if ($appState.currentRequest.approval) {
if ($appState.currentRequest.response) {
await respond();
}
})
});
</script>
<!-- Don't render at all if we're just going to immediately proceed to the next screen -->
{#if error || !$appState.currentRequest.approval}
{#if error || !$appState.currentRequest?.response}
<div class="flex flex-col space-y-4 p-4 m-auto max-w-xl h-screen items-center justify-center">
{#if error}
<ErrorAlert bind:this={alert}>
{error}
{error.msg}
<svelte:fragment slot="buttons">
<button class="btn btn-sm btn-alert-error" on:click={completeRequest}>Cancel</button>
<button class="btn btn-sm btn-alert-error" on:click={cleanupRequest}>Cancel</button>
<button class="btn btn-sm btn-alert-error" on:click={respond}>Retry</button>
</svelte:fragment>
</ErrorAlert>
{/if}
{#if $appState.currentRequest.base}
{#if $appState.currentRequest?.base}
<div class="alert alert-warning shadow-lg">
<div>
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span>
WARNING: This application is requesting your base (long-lived) AWS credentials.
WARNING: This application is requesting your base AWS credentials.
These credentials are less secure than session credentials, since they don't expire automatically.
</span>
</div>
@ -97,29 +104,49 @@
<h2 class="text-xl font-bold">{appName ? `"${appName}"` : 'An appplication'} would like to access your AWS credentials.</h2>
<div class="grid grid-cols-[auto_1fr] gap-x-3">
{#each $appState.currentRequest.clients as client}
<div class="text-right">Path:</div>
<code class="">{@html client ? breakPath(client) : 'Unknown'}</code>
<div class="text-right">PID:</div>
<code>{client ? client.pid : 'Unknown'}</code>
{/each}
<div class="text-right">Path:</div>
<code class="">{@html client.exe ? breakPath(client.exe) : 'Unknown'}</code>
<div class="text-right">PID:</div>
<code>{client.pid}</code>
</div>
</div>
<div class="w-full flex justify-between">
<Link target={deny} hotkey="Escape">
<button class="btn btn-error justify-self-start">
<span class="mr-2">Deny</span>
<KeyCombo keys={['Esc']} />
</button>
</Link>
<div class="w-full grid grid-cols-[1fr_auto] items-center gap-y-6">
<!-- Don't display the option to approve with session credentials if base was specifically requested -->
{#if !$appState.currentRequest?.base}
<h3 class="font-semibold">
Approve with session credentials
</h3>
<Link target={() => approve(false)} hotkey="Enter" shift={true}>
<button class="w-full btn btn-success">
<KeyCombo keys={['Shift', 'Enter']} />
</button>
</Link>
{/if}
<Link target={approve} hotkey="Enter" shift="{true}">
<button class="btn btn-success justify-self-end">
<span class="mr-2">Approve</span>
<KeyCombo keys={['Shift', 'Enter']} />
</button>
</Link>
<h3 class="font-semibold">
<span class="mr-2">
{#if $appState.currentRequest?.base}
Approve
{:else}
Approve with base credentials
{/if}
</span>
</h3>
<Link target={() => approve(true)} hotkey="Enter" shift={true} ctrl={true}>
<button class="w-full btn btn-warning">
<KeyCombo keys={['Ctrl', 'Shift', 'Enter']} />
</button>
</Link>
<h3 class="font-semibold">
<span class="mr-2">Deny</span>
</h3>
<Link target={deny} hotkey="Escape">
<button class="w-full btn btn-error">
<KeyCombo keys={['Esc']} />
</button>
</Link>
</div>
</div>
{/if}
{/if}

View File

@ -1,6 +1,7 @@
<script>
import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/tauri';
import { emit } from '@tauri-apps/api/event';
import { getRootCause } from '../lib/errors.js';
import { appState } from '../lib/state.js';

View File

@ -39,8 +39,8 @@
Launch Terminal
</button>
<label class="label cursor-pointer flex items-center space-x-2">
<span class="label-text">Launch with long-lived credentials</span>
<input type="checkbox" class="checkbox checkbox-sm" bind:checked={launchBase}>
<span class="label-text">Launch with base credentials</span>
</label>
{:else if status === 'empty'}

View File

@ -14,15 +14,18 @@
import { backInOut } from 'svelte/easing';
// make an independent copy so it can differ from the main config object
let config = JSON.parse(JSON.stringify($appState.config));
$: configModified = JSON.stringify(config) !== JSON.stringify($appState.config);
let error = null;
async function save() {
console.log('updating config');
try {
await invoke('save_config', {config: $appState.config});
await invoke('save_config', {config});
$appState.config = await invoke('get_config');
}
catch (e) {
error = e;
$appState.config = await invoke('get_config');
}
}
@ -35,74 +38,60 @@
<h1 slot="title" class="text-2xl font-bold">Settings</h1>
</Nav>
{#await invoke('get_config') then config}
<div class="max-w-lg mx-auto mt-1.5 p-4 space-y-16">
<SettingsGroup name="General">
<ToggleSetting title="Start on login" bind:value={$appState.config.start_on_login} on:update={save}>
<svelte:fragment slot="description">
Start Creddy when you log in to your computer.
</svelte:fragment>
</ToggleSetting>
<div class="max-w-lg mx-auto mt-1.5 mb-24 p-4 space-y-16">
<SettingsGroup name="General">
<ToggleSetting title="Start on login" bind:value={config.start_on_login}>
<svelte:fragment slot="description">
Start Creddy when you log in to your computer.
</svelte:fragment>
</ToggleSetting>
<ToggleSetting title="Start minimized" bind:value={$appState.config.start_minimized} on:update={save}>
<svelte:fragment slot="description">
Minimize to the system tray at startup.
</svelte:fragment>
</ToggleSetting>
<ToggleSetting title="Start minimized" bind:value={config.start_minimized}>
<svelte:fragment slot="description">
Minimize to the system tray at startup.
</svelte:fragment>
</ToggleSetting>
<NumericSetting title="Re-hide delay" bind:value={$appState.config.rehide_ms} min={0} unit="Milliseconds" on:update={save}>
<svelte:fragment slot="description">
How long to wait after a request is approved/denied before minimizing
the window to tray. Only applicable if the window was minimized
to tray before the request was received.
</svelte:fragment>
</NumericSetting>
<NumericSetting title="Re-hide delay" bind:value={config.rehide_ms} min={0} unit="Milliseconds">
<svelte:fragment slot="description">
How long to wait after a request is approved/denied before minimizing
the window to tray. Only applicable if the window was minimized
to tray before the request was received.
</svelte:fragment>
</NumericSetting>
<NumericSetting
title="Listen port"
bind:value={$appState.config.listen_port}
min={osType === 'Windows_NT' ? 1 : 0}
on:update={save}
>
<svelte:fragment slot="description">
Listen for credentials requests on this port.
(Should be used with <code>$AWS_CONTAINER_CREDENTIALS_FULL_URI</code>)
</svelte:fragment>
</NumericSetting>
<Setting title="Update credentials">
<Link slot="input" target="EnterCredentials">
<button class="btn btn-sm btn-primary">Update</button>
</Link>
<svelte:fragment slot="description">
Update or re-enter your encrypted credentials.
</svelte:fragment>
</Setting>
<Setting title="Update credentials">
<Link slot="input" target="EnterCredentials">
<button class="btn btn-sm btn-primary">Update</button>
</Link>
<svelte:fragment slot="description">
Update or re-enter your encrypted credentials.
</svelte:fragment>
</Setting>
<FileSetting
title="Terminal emulator"
bind:value={config.terminal.exec}
>
<svelte:fragment slot="description">
Choose your preferred terminal emulator (e.g. <code>gnome-terminal</code> or <code>wt.exe</code>.) May be an absolute path or an executable discoverable on <code>$PATH</code>.
</svelte:fragment>
</FileSetting>
</SettingsGroup>
<FileSetting
title="Terminal emulator"
bind:value={$appState.config.terminal.exec}
on:update={save}
>
<svelte:fragment slot="description">
Choose your preferred terminal emulator (e.g. <code>gnome-terminal</code> or <code>wt.exe</code>.) May be an absolute path or an executable discoverable on <code>$PATH</code>.
</svelte:fragment>
</FileSetting>
</SettingsGroup>
<SettingsGroup name="Hotkeys">
<div class="space-y-4">
<p>Click on a keybinding to modify it. Use the checkbox to enable or disable a keybinding entirely.</p>
<SettingsGroup name="Hotkeys">
<div class="space-y-4">
<p>Click on a keybinding to modify it. Use the checkbox to enable or disable a keybinding entirely.</p>
<div class="grid grid-cols-[auto_1fr_auto] gap-y-3 items-center">
<Keybind description="Show Creddy" value={$appState.config.hotkeys.show_window} on:update={save} />
<Keybind description="Launch terminal" value={$appState.config.hotkeys.launch_terminal} on:update={save} />
</div>
<div class="grid grid-cols-[auto_1fr_auto] gap-y-3 items-center">
<Keybind description="Show Creddy" bind:value={config.hotkeys.show_window} />
<Keybind description="Launch terminal" bind:value={config.hotkeys.launch_terminal} />
</div>
</SettingsGroup>
</div>
</SettingsGroup>
</div>
{/await}
</div>
{#if error}
<div transition:fly={{y: 100, easing: backInOut, duration: 400}} class="toast">
@ -116,4 +105,15 @@
</div>
</div>
</div>
{:else if configModified}
<div transition:fly={{y: 100, easing: backInOut, duration: 400}} class="toast">
<div class="alert shadow-lg no-animation">
<span>You have unsaved changes.</span>
<div>
<!-- <button class="btn btn-sm btn-ghost">Cancel</button> -->
<button class="btn btn-sm btn-primary" on:click={save}>Save</button>
</div>
</div>
</div>
{/if}

View File

@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import { draw, fade } from 'svelte/transition';
import { appState, completeRequest } from '../lib/state.js';
import { appState, cleanupRequest } from '../lib/state.js';
let success = false;
let error = null;
@ -13,7 +13,7 @@
onMount(() => {
window.setTimeout(
completeRequest,
cleanupRequest,
// Extra 50ms so the window can finish disappearing before the redraw
Math.min(5000, $appState.config.rehide_ms + 50),
)
@ -22,7 +22,7 @@
<div class="flex flex-col h-screen items-center justify-center max-w-max m-auto">
{#if $appState.currentRequest.approval === 'Approved'}
{#if $appState.currentRequest.response.approval === 'Approved'}
<svg xmlns="http://www.w3.org/2000/svg" class="w-36 h-36" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor">
<path in:draw="{{duration: drawDuration}}" stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
@ -33,6 +33,6 @@
{/if}
<div in:fade="{{duration: fadeDuration, delay: fadeDelay}}" class="text-2xl font-bold">
{$appState.currentRequest.approval}!
{$appState.currentRequest.response.approval}!
</div>
</div>