15 Commits

Author SHA1 Message Date
4c18de8b7a fix docker credential helper when credentials are not found 2024-12-28 06:59:09 -05:00
0cfa9fc07a correct server socket differentiation 2024-12-27 15:49:42 -05:00
9e9bc2b0ae separate dev and production instances and add visual indicators of dev mode 2024-12-27 08:17:49 -05:00
07bf98e522 bump version to 0.6.0 2024-11-25 14:58:53 -05:00
e0e758554c finish basic Docker credential helper implementation 2024-11-25 14:47:30 -05:00
479a0a96eb add Docker credentials to management page 2024-11-25 12:02:44 -05:00
c6e22fc91b show client username, check whether credential exists before requesting confirmation from frontend 2024-11-25 11:22:27 -05:00
9bc9cb56c1 finish extremely basic implementation of docker credentials 2024-11-25 07:58:02 -05:00
8bcdc5420a add CliRequest variants to store/erase docker credentials 2024-11-25 07:58:02 -05:00
0a355c299b working implementation of docker get 2024-11-25 07:58:02 -05:00
192d9058c3 send SaveCredential request to frontend on docker store 2024-11-25 07:58:02 -05:00
b88b32d0f1 add Docker credentials to app and CLI 2024-11-25 07:58:02 -05:00
12c97c4a7d start working on docker helper 2024-11-25 07:58:02 -05:00
97528d65d6 link visibility of passphrase inputs on EnterPassphrase page 2024-11-24 09:37:33 -05:00
295698e62f focus unlock input when window is focused 2024-09-18 09:29:14 -04:00
33 changed files with 704 additions and 224 deletions

View File

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

50
src-tauri/Cargo.lock generated
View File

@ -218,30 +218,6 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "async-executor"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8828ec6e544c02b0d6691d21ed9f9218d0384a82542855073c2a3f58304aaf0"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"slab",
]
[[package]]
name = "async-fs"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebcd09b382f40fcd159c2d695175b2ae620ffa5f3bd6f664131efff4e8b9e04a"
dependencies = [
"async-lock",
"blocking",
"futures-lite",
]
[[package]] [[package]]
name = "async-io" name = "async-io"
version = "2.3.3" version = "2.3.3"
@ -1241,7 +1217,7 @@ dependencies = [
[[package]] [[package]]
name = "creddy" name = "creddy"
version = "0.5.4" version = "0.6.2"
dependencies = [ dependencies = [
"argon2", "argon2",
"auto-launch", "auto-launch",
@ -1275,7 +1251,6 @@ dependencies = [
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-global-shortcut", "tauri-plugin-global-shortcut",
"tauri-plugin-os", "tauri-plugin-os",
"tauri-plugin-single-instance",
"thiserror", "thiserror",
"time", "time",
"tokio", "tokio",
@ -1287,7 +1262,7 @@ dependencies = [
[[package]] [[package]]
name = "creddy_cli" name = "creddy_cli"
version = "0.5.4" version = "0.6.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@ -5647,21 +5622,6 @@ dependencies = [
"thiserror", "thiserror",
] ]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.0.0-beta.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ecafcc5214a5d3cd7a720c11e9c03cbd45ccaff721963485ec4ab481bdf4540"
dependencies = [
"log",
"serde",
"serde_json",
"tauri",
"thiserror",
"windows-sys 0.52.0",
"zbus",
]
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.0.0-beta.18" version = "2.0.0-beta.18"
@ -7042,15 +7002,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b8e3d6ae3342792a6cc2340e4394334c7402f3d793b390d2c5494a4032b3030" checksum = "7b8e3d6ae3342792a6cc2340e4394334c7402f3d793b390d2c5494a4032b3030"
dependencies = [ dependencies = [
"async-broadcast", "async-broadcast",
"async-executor",
"async-fs",
"async-io",
"async-lock",
"async-process", "async-process",
"async-recursion", "async-recursion",
"async-task",
"async-trait", "async-trait",
"blocking",
"derivative", "derivative",
"enumflags2", "enumflags2",
"event-listener 5.3.1", "event-listener 5.3.1",

View File

@ -1,6 +1,6 @@
[package] [package]
name = "creddy" name = "creddy"
version = "0.5.4" version = "0.6.2"
description = "A friendly AWS credentials manager" description = "A friendly AWS credentials manager"
authors = ["Joseph Montanaro"] authors = ["Joseph Montanaro"]
license = "" license = ""
@ -49,7 +49,6 @@ chacha20poly1305 = { version = "0.10.1", features = ["std"] }
which = "4.4.0" which = "4.4.0"
windows = { version = "0.51.1", features = ["Win32_Foundation", "Win32_System_Pipes"] } windows = { version = "0.51.1", features = ["Win32_Foundation", "Win32_System_Pipes"] }
time = "0.3.31" time = "0.3.31"
tauri-plugin-single-instance = "2.0.0-beta.9"
tauri-plugin-global-shortcut = "2.0.0-beta.6" tauri-plugin-global-shortcut = "2.0.0-beta.6"
tauri-plugin-os = "2.0.0-beta.6" tauri-plugin-os = "2.0.0-beta.6"
tauri-plugin-dialog = "2.0.0-beta.9" tauri-plugin-dialog = "2.0.0-beta.9"

View File

@ -1,6 +1,6 @@
[package] [package]
name = "creddy_cli" name = "creddy_cli"
version = "0.5.4" version = "0.6.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]

View File

@ -1,10 +1,62 @@
use std::io; use std::io::{self, Read};
use crate::proto::DockerCredential; use anyhow::bail;
use crate::proto::{CliResponse, DockerCredential};
use super::{
CliCredential,
CliRequest,
GlobalArgs
};
pub fn docker_store() -> anyhow::Result<()> { pub fn docker_store(global_args: GlobalArgs) -> anyhow::Result<()> {
let input: DockerCredential = serde_json::from_reader(io::stdin())?; let input: DockerCredential = serde_json::from_reader(io::stdin())?;
dbg!(input);
let req = CliRequest::StoreDockerCredential(input);
match super::make_request(global_args.server_addr, &req)?? {
CliResponse::Empty => Ok(()),
r => bail!("Unexpected response from server: {r}"),
}
}
pub fn docker_get(global_args: GlobalArgs) -> anyhow::Result<()> {
let mut server_url = String::new();
io::stdin().read_to_string(&mut server_url)?;
let req = CliRequest::GetDockerCredential {
server_url: server_url.trim().to_owned()
};
let server_resp = super::make_request(global_args.server_addr, &req)?;
match server_resp {
Ok(CliResponse::Credential(CliCredential::Docker(d))) => {
println!("{}", serde_json::to_string(&d)?);
},
Err(e) if e.code == "NoCredentials" => {
// To indicate credentials are not found, a credential helper *must* print
// this message to stdout, then exit 1. Any other message/status will cause
// some builds to fail. This is, of course, not documented anywhere.
println!("credentials not found in native keychain");
std::process::exit(1);
},
Err(e) => Err(e)?,
Ok(r) => bail!("Unexpected response from server: {r}"),
}
Ok(()) Ok(())
} }
pub fn docker_erase(global_args: GlobalArgs) -> anyhow::Result<()> {
let mut server_url = String::new();
io::stdin().read_to_string(&mut server_url)?;
let req = CliRequest::EraseDockerCredential {
server_url: server_url.trim().to_owned()
};
match super::make_request(global_args.server_addr, &req)?? {
CliResponse::Empty => Ok(()),
r => bail!("Unexpected response from server: {r}"),
}
}

View File

@ -102,7 +102,7 @@ pub struct ExecArgs {
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct InvokeArgs { pub struct InvokeArgs {
#[arg(value_name = "ACTION", value_enum)] #[arg(value_name = "ACTION", value_enum)]
shortcut_action: ShortcutAction, pub shortcut_action: ShortcutAction,
} }
@ -118,7 +118,7 @@ pub enum DockerCmd {
pub fn get(args: GetArgs, global: GlobalArgs) -> anyhow::Result<()> { pub fn get(args: GetArgs, global: GlobalArgs) -> anyhow::Result<()> {
let req = CliRequest::GetCredential { let req = CliRequest::GetAwsCredential {
name: args.name, name: args.name,
base: args.base, base: args.base,
}; };
@ -145,7 +145,7 @@ pub fn exec(args: ExecArgs, global: GlobalArgs) -> anyhow::Result<()> {
let mut cmd = ChildCommand::new(cmd_name); let mut cmd = ChildCommand::new(cmd_name);
cmd.args(cmd_line); cmd.args(cmd_line);
let req = CliRequest::GetCredential { let req = CliRequest::GetAwsCredential {
name: args.get_args.name, name: args.get_args.name,
base: args.get_args.base, base: args.get_args.base,
}; };
@ -193,7 +193,7 @@ pub fn exec(args: ExecArgs, global: GlobalArgs) -> anyhow::Result<()> {
pub fn invoke_shortcut(args: InvokeArgs, global: GlobalArgs) -> anyhow::Result<()> { pub fn invoke_shortcut(args: InvokeArgs, global: GlobalArgs) -> anyhow::Result<()> {
let req = CliRequest::InvokeShortcut(args.shortcut_action); let req = CliRequest::InvokeShortcut{action: args.shortcut_action};
match make_request(global.server_addr, &req)?? { match make_request(global.server_addr, &req)?? {
CliResponse::Empty => Ok(()), CliResponse::Empty => Ok(()),
r => bail!("Unexpected response from server: {r}"), r => bail!("Unexpected response from server: {r}"),
@ -201,11 +201,11 @@ pub fn invoke_shortcut(args: InvokeArgs, global: GlobalArgs) -> anyhow::Result<(
} }
pub fn docker_credential_helper(cmd: DockerCmd) -> anyhow::Result<()> { pub fn docker_credential_helper(cmd: DockerCmd, global_args: GlobalArgs) -> anyhow::Result<()> {
match cmd { match cmd {
DockerCmd::Get => todo!(), DockerCmd::Get => docker::docker_get(global_args),
DockerCmd::Store => docker::docker_store(), DockerCmd::Store => docker::docker_store(global_args),
DockerCmd::Erase => todo!(), DockerCmd::Erase => docker::docker_erase(global_args),
} }
} }
@ -214,6 +214,7 @@ pub fn docker_credential_helper(cmd: DockerCmd) -> anyhow::Result<()> {
// to indicate when the operation succeeded or failed, which we deserialize. // to indicate when the operation succeeded or failed, which we deserialize.
// However, the operation may fail to even communicate with the server, in // However, the operation may fail to even communicate with the server, in
// which case we return the outer Result // which case we return the outer Result
// (probably this should be modeled differently)
#[tokio::main] #[tokio::main]
async fn make_request( async fn make_request(
addr: Option<PathBuf>, addr: Option<PathBuf>,

View File

@ -1,11 +1,12 @@
mod cli; mod cli;
pub use cli::{ pub use cli::{
Cli,
Action, Action,
Cli,
docker_credential_helper,
exec, exec,
get, get,
GlobalArgs,
invoke_shortcut, invoke_shortcut,
docker_credential_helper,
}; };
pub(crate) use platform::connect; pub(crate) use platform::connect;
@ -14,6 +15,12 @@ pub use platform::server_addr;
pub mod proto; pub mod proto;
pub fn show_window(global_args: GlobalArgs) -> anyhow::Result<()> {
let invoke = cli::InvokeArgs { shortcut_action: proto::ShortcutAction::ShowWindow };
cli::invoke_shortcut(invoke, global_args)
}
#[cfg(unix)] #[cfg(unix)]
mod platform { mod platform {
use std::path::PathBuf; use std::path::PathBuf;
@ -27,7 +34,12 @@ mod platform {
pub fn server_addr(sock_name: &str) -> PathBuf { pub fn server_addr(sock_name: &str) -> PathBuf {
let mut path = dirs::runtime_dir() let mut path = dirs::runtime_dir()
.unwrap_or_else(|| PathBuf::from("/tmp")); .unwrap_or_else(|| PathBuf::from("/tmp"));
path.push(format!("{sock_name}.sock")); if cfg!(debug_assertions) {
path.push(format!("{sock_name}.dev.sock"))
}
else {
path.push(format!("{sock_name}.sock"));
}
path path
} }
} }
@ -36,6 +48,11 @@ mod platform {
#[cfg(windows)] #[cfg(windows)]
mod platform { mod platform {
pub fn server_addr(sock_name: &str) -> String { pub fn server_addr(sock_name: &str) -> String {
format!(r"\\.\pipe\{sock_name}") if cfg!(debug_assertions) {
format!(r"\\.\pipe\{sock_name}.dev")
}
else {
format!(r"\\.\pipe\{sock_name}")
}
} }
} }

View File

@ -11,7 +11,7 @@ fn main() {
Some(Action::Get(args)) => creddy_cli::get(args, cli.global_args), Some(Action::Get(args)) => creddy_cli::get(args, cli.global_args),
Some(Action::Exec(args)) => creddy_cli::exec(args, cli.global_args), Some(Action::Exec(args)) => creddy_cli::exec(args, cli.global_args),
Some(Action::Shortcut(args)) => creddy_cli::invoke_shortcut(args, cli.global_args), Some(Action::Shortcut(args)) => creddy_cli::invoke_shortcut(args, cli.global_args),
Some(Action::Docker(cmd)) => creddy_cli::docker_credential_helper(cmd), Some(Action::Docker(cmd)) => creddy_cli::docker_credential_helper(cmd, cli.global_args),
}; };
if let Err(e) = res { if let Err(e) = res {

View File

@ -9,17 +9,22 @@ use serde::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum CliRequest { pub enum CliRequest {
GetCredential { GetAwsCredential {
name: Option<String>, name: Option<String>,
base: bool, base: bool,
}, },
SaveCredential { GetDockerCredential {
name: String, server_url: String,
is_default: bool, },
credential: CliCredential, StoreDockerCredential(DockerCredential),
EraseDockerCredential {
server_url: String,
},
InvokeShortcut{
action: ShortcutAction,
}, },
InvokeShortcut(ShortcutAction),
} }
@ -94,8 +99,8 @@ pub struct DockerCredential {
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct ServerError { pub struct ServerError {
code: String, pub code: String,
msg: String, pub msg: String,
} }
impl Display for ServerError { impl Display for ServerError {

View File

@ -7,5 +7,6 @@ CREATE TABLE docker_credentials (
server_url TEXT UNIQUE NOT NULL, server_url TEXT UNIQUE NOT NULL,
username TEXT NOT NULL, username TEXT NOT NULL,
secret_enc BLOB NOT NULL, secret_enc BLOB NOT NULL,
nonce BLOB NOT NULL nonce BLOB NOT NULL,
FOREIGN KEY(id) REFERENCES credentials(id) ON DELETE CASCADE
); );

View File

@ -15,7 +15,7 @@ use tauri::{
RunEvent, RunEvent,
WindowEvent, WindowEvent,
}; };
use tauri::menu::MenuItem; use creddy_cli::GlobalArgs;
use crate::{ use crate::{
config::{self, AppConfig}, config::{self, AppConfig},
@ -32,12 +32,13 @@ use crate::{
pub static APP: OnceCell<AppHandle> = OnceCell::new(); pub static APP: OnceCell<AppHandle> = OnceCell::new();
pub fn run() -> tauri::Result<()> { pub fn run(global_args: GlobalArgs) -> tauri::Result<()> {
if let Ok(_) = creddy_cli::show_window(global_args) {
// app is already running, so terminate
return Ok(());
}
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
show_main_window(app)
.error_popup("Failed to show main window")
}))
.plugin(tauri_plugin_global_shortcut::Builder::default().build()) .plugin(tauri_plugin_global_shortcut::Builder::default().build())
.plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
@ -58,6 +59,7 @@ pub fn run() -> tauri::Result<()> {
ipc::save_config, ipc::save_config,
ipc::launch_terminal, ipc::launch_terminal,
ipc::get_setup_errors, ipc::get_setup_errors,
ipc::get_devmode,
ipc::exit, ipc::exit,
]) ])
.setup(|app| rt::block_on(setup(app))) .setup(|app| rt::block_on(setup(app)))
@ -158,8 +160,8 @@ fn start_auto_locker(app: AppHandle) {
pub fn show_main_window(app: &AppHandle) -> Result<(), WindowError> { pub fn show_main_window(app: &AppHandle) -> Result<(), WindowError> {
let w = app.get_webview_window("main").ok_or(WindowError::NoMainWindow)?; let w = app.get_webview_window("main").ok_or(WindowError::NoMainWindow)?;
w.show()?; w.show()?;
let show_hide = app.state::<MenuItem<tauri::Wry>>(); let menu = app.state::<tray::MenuItems>();
show_hide.set_text("Hide")?; menu.after_show()?;
Ok(()) Ok(())
} }
@ -167,8 +169,8 @@ pub fn show_main_window(app: &AppHandle) -> Result<(), WindowError> {
pub fn hide_main_window(app: &AppHandle) -> Result<(), WindowError> { pub fn hide_main_window(app: &AppHandle) -> Result<(), WindowError> {
let w = app.get_webview_window("main").ok_or(WindowError::NoMainWindow)?; let w = app.get_webview_window("main").ok_or(WindowError::NoMainWindow)?;
w.hide()?; w.hide()?;
let show_hide = app.state::<MenuItem<tauri::Wry>>(); let menu = app.state::<tray::MenuItems>();
show_hide.set_text("Show")?; menu.after_hide()?;
Ok(()) Ok(())
} }

View File

@ -5,7 +5,8 @@ use sysinfo::{
SystemExt, SystemExt,
Pid, Pid,
PidExt, PidExt,
ProcessExt ProcessExt,
UserExt,
}; };
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
@ -16,13 +17,16 @@ use crate::errors::*;
pub struct Client { pub struct Client {
pub pid: u32, pub pid: u32,
pub exe: Option<PathBuf>, pub exe: Option<PathBuf>,
pub username: Option<String>,
} }
pub fn get_client(pid: u32, parent: bool) -> Result<Client, ClientInfoError> { pub fn get_client(pid: u32, parent: bool) -> Result<Client, ClientInfoError> {
let sys_pid = Pid::from_u32(pid); let sys_pid = Pid::from_u32(pid);
let mut sys = System::new(); let mut sys = System::new();
sys.refresh_process(sys_pid); sys.refresh_process(sys_pid);
sys.refresh_users_list();
let mut proc = sys.process(sys_pid) let mut proc = sys.process(sys_pid)
.ok_or(ClientInfoError::ProcessNotFound)?; .ok_or(ClientInfoError::ProcessNotFound)?;
@ -34,10 +38,15 @@ pub fn get_client(pid: u32, parent: bool) -> Result<Client, ClientInfoError> {
.ok_or(ClientInfoError::ParentProcessNotFound)?; .ok_or(ClientInfoError::ParentProcessNotFound)?;
} }
let username = proc.user_id()
.map(|uid| sys.get_user_by_id(uid))
.flatten()
.map(|u| u.name().to_owned());
let exe = match proc.exe() { let exe = match proc.exe() {
p if p == Path::new("") => None, p if p == Path::new("") => None,
p => Some(PathBuf::from(p)), p => Some(PathBuf::from(p)),
}; };
Ok(Client { pid: proc.pid().as_u32(), exe }) Ok(Client { pid: proc.pid().as_u32(), exe, username })
} }

View File

@ -31,7 +31,6 @@ pub struct DockerCredential {
pub secret: String, pub secret: String,
} }
impl PersistentCredential for DockerCredential { impl PersistentCredential for DockerCredential {
type Row = DockerRow; type Row = DockerRow;

View File

@ -83,6 +83,23 @@ pub trait PersistentCredential: for<'a> Deserialize<'a> + Sized {
Self::from_row(row, crypto) Self::from_row(row, crypto)
} }
async fn load_by<T>(column: &str, value: T, crypto: &Crypto, pool: &SqlitePool) -> Result<Self, LoadCredentialsError>
where T: Send + for<'q> sqlx::Encode<'q, Sqlite> + sqlx::Type<Sqlite>
{
let query = format!(
"SELECT * FROM {} where {} = ?",
Self::table_name(),
column,
);
let row: Self::Row = sqlx::query_as(&query)
.bind(value)
.fetch_optional(pool)
.await?
.ok_or(LoadCredentialsError::NoCredentials)?;
Self::from_row(row, crypto)
}
async fn load_default(crypto: &Crypto, pool: &SqlitePool) -> Result<Self, LoadCredentialsError> { async fn load_default(crypto: &Crypto, pool: &SqlitePool) -> Result<Self, LoadCredentialsError> {
let q = format!( let q = format!(
"SELECT details.* "SELECT details.*
@ -122,3 +139,10 @@ pub trait PersistentCredential: for<'a> Deserialize<'a> + Sized {
Ok(creds) Ok(creds)
} }
} }
pub fn random_uuid() -> Uuid {
// a bit weird to use salt() for this, but it's convenient
let random_bytes = Crypto::salt();
Uuid::from_slice(&random_bytes[..16]).unwrap()
}

View File

@ -173,7 +173,7 @@ pub enum HandlerError {
StreamIOError(#[from] std::io::Error), StreamIOError(#[from] std::io::Error),
#[error("Received invalid UTF-8 in request")] #[error("Received invalid UTF-8 in request")]
InvalidUtf8(#[from] FromUtf8Error), InvalidUtf8(#[from] FromUtf8Error),
#[error("HTTP request malformed")] #[error("Request malformed: {0}")]
BadRequest(#[from] serde_json::Error), BadRequest(#[from] serde_json::Error),
#[error("HTTP request too large")] #[error("HTTP request too large")]
RequestTooLarge, RequestTooLarge,
@ -183,6 +183,8 @@ pub enum HandlerError {
Internal(#[from] RecvError), Internal(#[from] RecvError),
#[error("Error accessing credentials: {0}")] #[error("Error accessing credentials: {0}")]
NoCredentials(#[from] GetCredentialsError), NoCredentials(#[from] GetCredentialsError),
#[error("Error saving credentials: {0}")]
SaveCredentials(#[from] SaveCredentialsError),
#[error("Error getting client details: {0}")] #[error("Error getting client details: {0}")]
ClientInfo(#[from] ClientInfoError), ClientInfo(#[from] ClientInfoError),
#[error("Error from Tauri: {0}")] #[error("Error from Tauri: {0}")]

View File

@ -14,9 +14,16 @@ use crate::state::AppState;
use crate::terminal; use crate::terminal;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum RequestAction {
Access,
Delete,
Save,
}
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AwsRequestNotification { pub struct AwsRequestNotification {
pub id: u64,
pub client: Client, pub client: Client,
pub name: Option<String>, pub name: Option<String>,
pub base: bool, pub base: bool,
@ -25,27 +32,47 @@ pub struct AwsRequestNotification {
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SshRequestNotification { pub struct SshRequestNotification {
pub id: u64,
pub client: Client, pub client: Client,
pub key_name: String, pub key_name: String,
} }
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "type")] pub struct DockerRequestNotification {
pub enum RequestNotification { pub action: RequestAction,
Aws(AwsRequestNotification), pub client: Client,
Ssh(SshRequestNotification), pub server_url: String,
} }
impl RequestNotification {
pub fn new_aws(id: u64, client: Client, name: Option<String>, base: bool) -> Self { #[derive(Clone, Debug, Serialize, Deserialize)]
Self::Aws(AwsRequestNotification {id, client, name, base}) #[serde(tag = "type")]
pub enum RequestNotificationDetail {
Aws(AwsRequestNotification),
Ssh(SshRequestNotification),
Docker(DockerRequestNotification),
}
impl RequestNotificationDetail {
pub fn new_aws(client: Client, name: Option<String>, base: bool) -> Self {
Self::Aws(AwsRequestNotification {client, name, base})
} }
pub fn new_ssh(id: u64, client: Client, key_name: String) -> Self { pub fn new_ssh(client: Client, key_name: String) -> Self {
Self::Ssh(SshRequestNotification {id, client, key_name}) Self::Ssh(SshRequestNotification {client, key_name})
} }
pub fn new_docker(action: RequestAction, client: Client, server_url: String) -> Self {
Self::Docker(DockerRequestNotification {action, client, server_url})
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RequestNotification {
pub id: u64,
#[serde(flatten)]
pub detail: RequestNotificationDetail,
} }
@ -177,6 +204,12 @@ pub async fn get_setup_errors(app_state: State<'_, AppState>) -> Result<Vec<Stri
} }
#[tauri::command]
pub fn get_devmode() -> bool {
cfg!(debug_assertions)
}
#[tauri::command] #[tauri::command]
pub fn exit(app_handle: AppHandle) { pub fn exit(app_handle: AppHandle) {
app_handle.exit(0) app_handle.exit(0)

View File

@ -15,13 +15,13 @@ fn main() {
let cli = Cli::parse(); let cli = Cli::parse();
let res = match cli.action { let res = match cli.action {
None | Some(Action::Run) => { None | Some(Action::Run) => {
app::run().error_popup("Creddy encountered an error"); app::run(cli.global_args).error_popup("Creddy encountered an error");
Ok(()) Ok(())
}, },
Some(Action::Get(args)) => creddy_cli::get(args, cli.global_args), Some(Action::Get(args)) => creddy_cli::get(args, cli.global_args),
Some(Action::Exec(args)) => creddy_cli::exec(args, cli.global_args), Some(Action::Exec(args)) => creddy_cli::exec(args, cli.global_args),
Some(Action::Shortcut(args)) => creddy_cli::invoke_shortcut(args, cli.global_args), Some(Action::Shortcut(args)) => creddy_cli::invoke_shortcut(args, cli.global_args),
Some(Action::Docker(cmd)) => creddy_cli::docker_credential_helper(cmd), Some(Action::Docker(cmd)) => creddy_cli::docker_credential_helper(cmd, cli.global_args),
}; };
if let Err(e) = res { if let Err(e) = res {

View File

@ -6,12 +6,11 @@ use ssh_agent_lib::proto::message::{
}; };
use tauri::{AppHandle, Manager}; use tauri::{AppHandle, Manager};
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
use tokio::sync::oneshot;
use tokio_util::codec::Framed; use tokio_util::codec::Framed;
use crate::clientinfo; use crate::clientinfo;
use crate::errors::*; use crate::errors::*;
use crate::ipc::{Approval, RequestNotification}; use crate::ipc::{Approval, RequestNotificationDetail};
use crate::state::AppState; use crate::state::AppState;
use super::{CloseWaiter, Stream}; use super::{CloseWaiter, Stream};
@ -40,7 +39,7 @@ async fn handle(
// corrupt the framing. Clients don't seem to behave that way though? // corrupt the framing. Clients don't seem to behave that way though?
let waiter = CloseWaiter { stream: adapter.get_mut() }; let waiter = CloseWaiter { stream: adapter.get_mut() };
let resp = sign_request(req, app_handle.clone(), client_pid, waiter).await?; let resp = sign_request(req, app_handle.clone(), client_pid, waiter).await?;
// have to do this before we send since we can't inspect the message after // have to do this before we send since we can't inspect the message after
let is_failure = matches!(resp, Message::Failure); let is_failure = matches!(resp, Message::Failure);
adapter.send(resp).await?; adapter.send(resp).await?;
@ -69,47 +68,21 @@ async fn sign_request(
req: SignRequest, req: SignRequest,
app_handle: AppHandle, app_handle: AppHandle,
client_pid: u32, client_pid: u32,
mut waiter: CloseWaiter<'_>, waiter: CloseWaiter<'_>,
) -> Result<Message, HandlerError> { ) -> Result<Message, HandlerError> {
let state = app_handle.state::<AppState>(); let state = app_handle.state::<AppState>();
let rehide_ms = {
let config = state.config.read().await;
config.rehide_ms
};
let client = clientinfo::get_client(client_pid, false)?; let client = clientinfo::get_client(client_pid, false)?;
let lease = state.acquire_visibility_lease(rehide_ms).await let key_name = state.ssh_name_from_pubkey(&req.pubkey_blob).await?;
.map_err(|_e| HandlerError::NoMainWindow)?; let detail = RequestNotificationDetail::new_ssh(client, key_name.clone());
let (chan_send, chan_recv) = oneshot::channel(); let response = super::send_credentials_request(detail, app_handle.clone(), waiter).await?;
let request_id = state.register_request(chan_send).await; match response.approval {
Approval::Approved => {
let proceed = async { let key = state.sshkey_by_name(&key_name).await?;
let key_name = state.ssh_name_from_pubkey(&req.pubkey_blob).await?; let sig = key.sign_request(&req)?;
let notification = RequestNotification::new_ssh(request_id, client, key_name.clone()); Ok(Message::SignResponse(sig))
app_handle.emit("credential-request", &notification)?; },
Approval::Denied => Err(HandlerError::Abandoned),
let response = tokio::select! {
r = chan_recv => r?,
_ = waiter.wait_for_close() => {
app_handle.emit("request-cancelled", request_id)?;
return Err(HandlerError::Abandoned);
},
};
if let Approval::Denied = response.approval {
return Ok(Message::Failure);
}
let key = state.sshkey_by_name(&key_name).await?;
let sig = key.sign_request(&req)?;
Ok(Message::SignResponse(sig))
};
let res = proceed.await;
if let Err(_) = &res {
state.unregister_request(request_id).await;
} }
lease.release();
res
} }

View File

@ -1,10 +1,19 @@
use tauri::{AppHandle, Manager}; use tauri::{AppHandle, Manager};
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::oneshot;
use crate::clientinfo::{self, Client}; use crate::clientinfo::{self, Client};
use crate::credentials::{
self,
Credential,
CredentialRecord,
DockerCredential,
};
use crate::errors::*; use crate::errors::*;
use crate::ipc::{Approval, RequestNotification}; use crate::ipc::{
Approval,
RequestAction,
RequestNotificationDetail
};
use crate::shortcuts::{self, ShortcutAction}; use crate::shortcuts::{self, ShortcutAction};
use crate::state::AppState; use crate::state::AppState;
use super::{ use super::{
@ -46,10 +55,19 @@ async fn handle(
let req: CliRequest = serde_json::from_slice(&buf)?; let req: CliRequest = serde_json::from_slice(&buf)?;
let res = match req { let res = match req {
CliRequest::GetCredential{ name, base } => get_aws_credentials( CliRequest::GetAwsCredential{ name, base } => get_aws_credentials(
name, base, client, app_handle, waiter name, base, client, app_handle, waiter
).await, ).await,
CliRequest::InvokeShortcut(action) => invoke_shortcut(action).await, CliRequest::GetDockerCredential{ server_url } => get_docker_credential (
server_url, client, app_handle, waiter
).await,
CliRequest::StoreDockerCredential(docker_credential) => store_docker_credential(
docker_credential, app_handle, client, waiter
).await,
CliRequest::EraseDockerCredential { server_url } => erase_docker_credential(
server_url, app_handle, client, waiter
).await,
CliRequest::InvokeShortcut{ action } => invoke_shortcut(action).await,
}; };
// doesn't make sense to send the error to the client if the client has already left // doesn't make sense to send the error to the client if the client has already left
@ -74,59 +92,132 @@ async fn get_aws_credentials(
base: bool, base: bool,
client: Client, client: Client,
app_handle: AppHandle, app_handle: AppHandle,
mut waiter: CloseWaiter<'_>, waiter: CloseWaiter<'_>,
) -> Result<CliResponse, HandlerError> {
let detail = RequestNotificationDetail::new_aws(client, name.clone(), base);
let response = super::send_credentials_request(detail, app_handle.clone(), waiter).await?;
match response.approval {
Approval::Approved => {
let state = app_handle.state::<AppState>();
if response.base {
let creds = state.get_aws_base(name).await?;
Ok(CliResponse::Credential(CliCredential::AwsBase(creds)))
}
else {
let creds = state.get_aws_session(name).await?.clone();
Ok(CliResponse::Credential(CliCredential::AwsSession(creds)))
}
},
Approval::Denied => Err(HandlerError::Denied),
}
}
async fn get_docker_credential(
server_url: String,
client: Client,
app_handle: AppHandle,
waiter: CloseWaiter<'_>,
) -> Result<CliResponse, HandlerError> { ) -> Result<CliResponse, HandlerError> {
let state = app_handle.state::<AppState>(); let state = app_handle.state::<AppState>();
let rehide_ms = { let meta = state.docker_credential_meta(&server_url).await.unwrap_or(None);
let config = state.config.read().await; if meta.is_none() {
config.rehide_ms return Err(
}; HandlerError::NoCredentials(
let lease = state.acquire_visibility_lease(rehide_ms).await GetCredentialsError::Load(
.map_err(|_e| HandlerError::NoMainWindow)?; // automate this conversion eventually? LoadCredentialsError::NoCredentials
)
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 = RequestNotification::new_aws(
request_id, client, name.clone(), base
); );
app_handle.emit("credential-request", &notification)?; }
let response = tokio::select! { let detail = RequestNotificationDetail::new_docker(
r = chan_recv => r?, RequestAction::Access,
_ = waiter.wait_for_close() => { client,
app_handle.emit("request-cancelled", request_id)?; server_url.clone()
return Err(HandlerError::Abandoned); );
}, let response = super::send_credentials_request(detail, app_handle.clone(), waiter).await?;
}; match response.approval {
Approval::Approved => {
match response.approval { let creds = state.get_docker_credential(&server_url).await?;
Approval::Approved => { Ok(CliResponse::Credential(CliCredential::Docker(creds)))
if response.base {
let creds = state.get_aws_base(name).await?;
Ok(CliResponse::Credential(CliCredential::AwsBase(creds)))
}
else {
let creds = state.get_aws_session(name).await?.clone();
Ok(CliResponse::Credential(CliCredential::AwsSession(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)
}, },
Approval::Denied => {
Err(HandlerError::Denied)
},
}
}
async fn store_docker_credential(
docker_credential: DockerCredential,
app_handle: AppHandle,
client: Client,
waiter: CloseWaiter<'_>,
) -> Result<CliResponse, HandlerError> {
let state = app_handle.state::<AppState>();
// We want to do this before asking for confirmation from the user, because Docker has an annoying
// habit of calling `get` and then immediately turning around and calling `store` with the same
// data. In that case we want to avoid asking for confirmation at all.
match state.get_docker_credential(&docker_credential.server_url).await {
// if there is already a credential with this server_url, and it is unchanged, we're done
Ok(c) if c == docker_credential => return Ok(CliResponse::Empty),
// otherwise we are making an update, so proceed
Ok(_) => (),
// if the app is locked, then this isn't the situation described above, so proceed
Err(GetCredentialsError::Locked) => (),
// if the app is unlocked, and there is no matching credential, proceed
Err(GetCredentialsError::Load(LoadCredentialsError::NoCredentials)) => (),
// any other error is a failure
Err(e) => return Err(e.into()),
}; };
lease.release(); let detail = RequestNotificationDetail::new_docker(
result RequestAction::Save,
client,
docker_credential.server_url.clone(),
);
let response = super::send_credentials_request(detail, app_handle.clone(), waiter).await?;
if matches!(response.approval, Approval::Denied) {
return Err(HandlerError::Denied);
}
let (id, name) = state.docker_credential_meta(&docker_credential.server_url)
.await
.map_err(|e| GetCredentialsError::Load(e))?
.unwrap_or_else(|| (credentials::random_uuid(), docker_credential.server_url.clone()));
let record = CredentialRecord {
id,
name,
is_default: false,
credential: Credential::Docker(docker_credential)
};
state.save_credential(record).await?;
Ok(CliResponse::Empty)
}
async fn erase_docker_credential(
server_url: String,
app_handle: AppHandle,
client: Client,
waiter: CloseWaiter<'_>
) -> Result<CliResponse, HandlerError> {
let state = app_handle.state::<AppState>();
let detail = RequestNotificationDetail::new_docker(
RequestAction::Delete,
client,
server_url.clone(),
);
let resp = super::send_credentials_request(detail, app_handle.clone(), waiter).await?;
match resp.approval {
Approval::Approved => {
state.delete_credential_by_name(&server_url).await?;
Ok(CliResponse::Empty)
}
Approval::Denied => {
Err(HandlerError::Denied)
}
}
} }

View File

@ -3,13 +3,21 @@ use std::future::Future;
use tauri::{ use tauri::{
AppHandle, AppHandle,
async_runtime as rt, async_runtime as rt,
Manager,
}; };
use tokio::io::AsyncReadExt; use tokio::io::AsyncReadExt;
use tokio::sync::oneshot;
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use crate::credentials::{AwsBaseCredential, AwsSessionCredential}; use crate::credentials::{
AwsBaseCredential,
AwsSessionCredential,
DockerCredential,
};
use crate::errors::*; use crate::errors::*;
use crate::ipc::{RequestNotification, RequestNotificationDetail, RequestResponse};
use crate::shortcuts::ShortcutAction; use crate::shortcuts::ShortcutAction;
use crate::state::AppState;
pub mod creddy_server; pub mod creddy_server;
pub mod agent; pub mod agent;
@ -20,12 +28,22 @@ use platform::Stream;
// so that we avoid polluting the standalone CLI with a bunch of dependencies // so that we avoid polluting the standalone CLI with a bunch of dependencies
// that would make it impossible to build a completely static-linked version // that would make it impossible to build a completely static-linked version
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum CliRequest { pub enum CliRequest {
GetCredential { GetAwsCredential {
name: Option<String>, name: Option<String>,
base: bool, base: bool,
}, },
InvokeShortcut(ShortcutAction), GetDockerCredential {
server_url: String,
},
StoreDockerCredential(DockerCredential),
EraseDockerCredential {
server_url: String,
},
InvokeShortcut{
action: ShortcutAction,
},
} }
@ -40,6 +58,7 @@ pub enum CliResponse {
pub enum CliCredential { pub enum CliCredential {
AwsBase(AwsBaseCredential), AwsBase(AwsBaseCredential),
AwsSession(AwsSessionCredential), AwsSession(AwsSessionCredential),
Docker(DockerCredential),
} }
@ -87,6 +106,48 @@ fn serve<H, F>(sock_name: &str, app_handle: AppHandle, handler: H) -> std::io::R
} }
async fn send_credentials_request(
detail: RequestNotificationDetail,
app_handle: AppHandle,
mut waiter: CloseWaiter<'_>
) -> Result<RequestResponse, 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)?;
let (chan_send, chan_recv) = oneshot::channel();
let request_id = state.register_request(chan_send).await;
let notification = RequestNotification { id: request_id, detail };
// the following could fail in various ways, but we want to make sure
// the request gets unregistered on any failure, so we wrap this all
// up in an async block so that we only have to handle the error case once
let proceed = async {
app_handle.emit("credential-request", &notification)?;
tokio::select! {
r = chan_recv => Ok(r?),
_ = waiter.wait_for_close() => {
app_handle.emit("request-cancelled", request_id)?;
Err(HandlerError::Abandoned)
},
}
};
let res = proceed.await;
if let Err(_) = &res {
state.unregister_request(request_id).await;
}
lease.release();
res
}
#[cfg(unix)] #[cfg(unix)]
mod platform { mod platform {
use std::io::ErrorKind; use std::io::ErrorKind;

View File

@ -19,6 +19,7 @@ use crate::app;
use crate::credentials::{ use crate::credentials::{
AppSession, AppSession,
AwsSessionCredential, AwsSessionCredential,
DockerCredential,
SshKey, SshKey,
}; };
use crate::{config, config::AppConfig}; use crate::{config, config::AppConfig};
@ -31,6 +32,7 @@ use crate::credentials::{
use crate::ipc::{self, RequestResponse}; use crate::ipc::{self, RequestResponse};
use crate::errors::*; use crate::errors::*;
use crate::shortcuts; use crate::shortcuts;
use crate::tray;
#[derive(Debug)] #[derive(Debug)]
@ -160,6 +162,13 @@ impl AppState {
Ok(()) Ok(())
} }
pub async fn delete_credential_by_name(&self, name: &str) -> Result<(), SaveCredentialsError> {
sqlx::query!("DELETE FROM credentials WHERE name = ?", name)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn list_credentials(&self) -> Result<Vec<CredentialRecord>, GetCredentialsError> { pub async fn list_credentials(&self) -> Result<Vec<CredentialRecord>, GetCredentialsError> {
let session = self.app_session.read().await; let session = self.app_session.read().await;
let crypto = session.try_get_crypto()?; let crypto = session.try_get_crypto()?;
@ -193,7 +202,7 @@ impl AppState {
pub async fn update_config(&self, new_config: AppConfig) -> Result<(), SetupError> { pub async fn update_config(&self, new_config: AppConfig) -> Result<(), SetupError> {
let mut live_config = self.config.write().await; let mut live_config = self.config.write().await;
// update autostart if necessary // update autostart if necessary
if new_config.start_on_login != live_config.start_on_login { if new_config.start_on_login != live_config.start_on_login {
config::set_auto_launch(new_config.start_on_login)?; config::set_auto_launch(new_config.start_on_login)?;
@ -244,7 +253,11 @@ impl AppState {
pub async fn unlock(&self, passphrase: &str) -> Result<(), UnlockError> { pub async fn unlock(&self, passphrase: &str) -> Result<(), UnlockError> {
let mut session = self.app_session.write().await; let mut session = self.app_session.write().await;
session.unlock(passphrase) session.unlock(passphrase)?;
let app_handle = app::APP.get().unwrap();
let menu = app_handle.state::<tray::MenuItems>();
let _ = menu.after_unlock(); // we don't care if this fails, it's non-essential
Ok(())
} }
pub async fn lock(&self) -> Result<(), LockError> { pub async fn lock(&self) -> Result<(), LockError> {
@ -258,6 +271,9 @@ impl AppState {
let app_handle = app::APP.get().unwrap(); let app_handle = app::APP.get().unwrap();
app_handle.emit("locked", None::<usize>)?; app_handle.emit("locked", None::<usize>)?;
let menu = app_handle.state::<tray::MenuItems>();
let _ = menu.after_lock();
Ok(()) Ok(())
} }
} }
@ -322,6 +338,30 @@ impl AppState {
Ok(k) Ok(k)
} }
pub async fn docker_credential_meta(
&self, server_url: &str
) -> Result<Option<(Uuid, String)>, LoadCredentialsError> {
let res = sqlx::query!(
r#"SELECT
c.id as "id: Uuid",
c.name
FROM
credentials c
JOIN docker_credentials d
ON d.id = c.id
WHERE d.server_url = ?"#,
server_url
).fetch_optional(&self.pool).await?;
Ok(res.map(|row| (row.id, row.name)))
}
pub async fn get_docker_credential(&self, server_url: &str) -> Result<DockerCredential, GetCredentialsError> {
let app_session = self.app_session.read().await;
let crypto = app_session.try_get_crypto()?;
let d = DockerCredential::load_by("server_url", server_url.to_owned(), crypto, &self.pool).await?;
Ok(d)
}
pub async fn signal_activity(&self) { pub async fn signal_activity(&self) {
let mut last_activity = self.last_activity.write().await; let mut last_activity = self.last_activity.write().await;
*last_activity = OffsetDateTime::now_utc(); *last_activity = OffsetDateTime::now_utc();

View File

@ -7,27 +7,74 @@ use tauri::{
use tauri::menu::{ use tauri::menu::{
MenuBuilder, MenuBuilder,
MenuEvent, MenuEvent,
MenuItem,
MenuItemBuilder, MenuItemBuilder,
PredefinedMenuItem,
}; };
use crate::app; use crate::app;
use crate::state::AppState; use crate::state::AppState;
pub struct MenuItems {
pub status: MenuItem<tauri::Wry>,
pub show_hide: MenuItem<tauri::Wry>,
}
impl MenuItems {
pub fn after_show(&self) -> tauri::Result<()> {
self.show_hide.set_text("Hide")
}
pub fn after_hide(&self) -> tauri::Result<()> {
self.show_hide.set_text("Show")
}
pub fn after_lock(&self) -> tauri::Result<()> {
if cfg!(debug_assertions) {
self.status.set_text("Creddy (dev): Locked")
}
else {
self.status.set_text("Creddy: Locked")
}
}
pub fn after_unlock(&self) -> tauri::Result<()> {
if cfg!(debug_assertions) {
self.status.set_text("Creddy (dev): Unlocked")
}
else {
self.status.set_text("Creddy: Unlocked")
}
}
}
pub fn setup(app: &App) -> tauri::Result<()> { pub fn setup(app: &App) -> tauri::Result<()> {
let status_text =
if cfg!(debug_assertions) {
"Creddy (dev): Locked"
}
else {
"Creddy: Locked"
};
let status = MenuItemBuilder::with_id("status", status_text)
.enabled(false)
.build(app)?;
let sep = PredefinedMenuItem::separator(app)?;
let show_hide = MenuItemBuilder::with_id("show_hide", "Show").build(app)?; let show_hide = MenuItemBuilder::with_id("show_hide", "Show").build(app)?;
let exit = MenuItemBuilder::with_id("exit", "Exit").build(app)?; let exit = MenuItemBuilder::with_id("exit", "Exit").build(app)?;
let menu = MenuBuilder::new(app) let menu = MenuBuilder::new(app)
.items(&[&show_hide, &exit]) .items(&[&status, &sep, &show_hide, &exit]);
.build()?;
let tray = app.tray_by_id("main").unwrap(); let tray = app.tray_by_id("main").unwrap();
tray.set_menu(Some(menu))?; tray.set_menu(Some(menu.build()?))?;
tray.on_menu_event(handle_event); tray.on_menu_event(handle_event);
// stash this so we can find it later to change the text // stash these so we can find them later to change the text
app.manage(show_hide); app.manage(MenuItems { status, show_hide });
Ok(()) Ok(())
} }

View File

@ -50,7 +50,7 @@
} }
}, },
"productName": "creddy", "productName": "creddy",
"version": "0.5.4", "version": "0.6.2",
"identifier": "creddy", "identifier": "creddy",
"plugins": {}, "plugins": {},
"app": { "app": {

View File

@ -14,6 +14,7 @@ import Unlock from './views/Unlock.svelte';
// set up app state // set up app state
invoke('get_config').then(config => $appState.config = config); invoke('get_config').then(config => $appState.config = config);
invoke('get_session_status').then(status => $appState.sessionStatus = status); invoke('get_session_status').then(status => $appState.sessionStatus = status);
invoke('get_devmode').then(dm => $appState.devmode = dm)
getVersion().then(version => $appState.appVersion = version); getVersion().then(version => $appState.appVersion = version);
invoke('get_setup_errors') invoke('get_setup_errors')
.then(errs => { .then(errs => {
@ -51,7 +52,7 @@ acceptRequest();
</script> </script>
<svelte:window <svelte:window
on:click={() => invoke('signal_activity')} on:click={() => invoke('signal_activity')}
on:keydown={() => invoke('signal_activity')} on:keydown={() => invoke('signal_activity')}
/> />
@ -70,3 +71,9 @@ acceptRequest();
<!-- normal operation --> <!-- normal operation -->
<svelte:component this="{$currentView}" /> <svelte:component this="{$currentView}" />
{/if} {/if}
{#if $appState.devmode }
<div class="fixed left-0 bottom-0 right-0 py-1 bg-warning text-xs text-center text-warning-content">
This is a development build of Creddy.
</div>
{/if}

View File

@ -4,10 +4,10 @@
export let value = ''; export let value = '';
export let placeholder = ''; export let placeholder = '';
export let autofocus = false; export let autofocus = false;
export let show = false;
let classes = ''; let classes = '';
export {classes as class}; export {classes as class};
let show = false;
let input; let input;
export function focus() { export function focus() {

View File

@ -7,6 +7,7 @@
import ShowResponse from './approve/ShowResponse.svelte'; import ShowResponse from './approve/ShowResponse.svelte';
import Unlock from './Unlock.svelte'; import Unlock from './Unlock.svelte';
console.log($appState.currentRequest);
// Extra 50ms so the window can finish disappearing before the redraw // Extra 50ms so the window can finish disappearing before the redraw
const rehideDelay = Math.min(5000, $appState.config.rehide_ms + 100); const rehideDelay = Math.min(5000, $appState.config.rehide_ms + 100);

View File

@ -6,9 +6,8 @@
import AwsCredential from './credentials/AwsCredential.svelte'; import AwsCredential from './credentials/AwsCredential.svelte';
import ConfirmDelete from './credentials/ConfirmDelete.svelte'; import ConfirmDelete from './credentials/ConfirmDelete.svelte';
import DockerCredential from './credentials/DockerCredential.svelte';
import SshKey from './credentials/SshKey.svelte'; import SshKey from './credentials/SshKey.svelte';
// import NewSshKey from './credentials/NewSshKey.svelte';
// import EditSshKey from './credentials/EditSshKey.svelte';
import Icon from '../ui/Icon.svelte'; import Icon from '../ui/Icon.svelte';
import Nav from '../ui/Nav.svelte'; import Nav from '../ui/Nav.svelte';
@ -16,6 +15,7 @@
let records = null let records = null
$: awsRecords = (records || []).filter(r => r.credential.type === 'AwsBase'); $: awsRecords = (records || []).filter(r => r.credential.type === 'AwsBase');
$: sshRecords = (records || []).filter(r => r.credential.type === 'Ssh'); $: sshRecords = (records || []).filter(r => r.credential.type === 'Ssh');
$: dockerRecords = (records || []).filter(r => r.credential.type === 'Docker');
let defaults = writable({}); let defaults = writable({});
async function loadCreds() { async function loadCreds() {
@ -47,6 +47,17 @@
records = records; records = records;
} }
function newDocker() {
records.push({
id: crypto.randomUUID(),
name: null,
is_default: false,
credential: {type: 'Docker', ServerURL: '', Username: '', Secret: ''},
isNew: true,
});
records = records;
}
let confirmDelete; let confirmDelete;
function handleDelete(evt) { function handleDelete(evt) {
const record = evt.detail; const record = evt.detail;
@ -117,6 +128,29 @@
{/if} {/if}
</div> </div>
<div class="flex flex-col gap-y-4">
<div class="divider">
<h2 class="text-xl font-bold">Docker credentials</h2>
</div>
{#if dockerRecords.length > 0}
{#each dockerRecords as record (record.id)}
<DockerCredential {record} on:save={loadCreds} on:delete={handleDelete} />
{/each}
<button class="btn btn-primary btn-wide mx-auto" on:click={newDocker}>
<Icon name="plus-circle-mini" class="size-5" />
Add
</button>
{:else if records !== null}
<div class="flex flex-col gap-6 items-center rounded-box border-2 border-dashed border-neutral-content/30 p-6">
<div>You have no saved Docker credentials.</div>
<button class="btn btn-primary btn-wide mx-auto" on:click={newDocker}>
<Icon name="plus-circle-mini" class="size-5" />
Add
</button>
</div>
{/if}
</div>
</div> </div>
<ConfirmDelete bind:this={confirmDelete} on:confirm={loadCreds} /> <ConfirmDelete bind:this={confirmDelete} on:confirm={loadCreds} />

View File

@ -19,7 +19,7 @@
let alert; let alert;
let passphrase = ''; let passphrase = '';
let saving = false; let saving = false;
async function unlock() { async function unlock() {
saving = true; saving = true;
@ -40,6 +40,8 @@
</script> </script>
<svelte:window on:focus={input.focus} />
<div class="fixed top-0 w-full p-2 text-center"> <div class="fixed top-0 w-full p-2 text-center">
<h1 class="text-3xl font-bold">Creddy is locked</h1> <h1 class="text-3xl font-bold">Creddy is locked</h1>
</div> </div>

View File

@ -14,7 +14,7 @@
// Extract executable name from full path // Extract executable name from full path
const client = $appState.currentRequest.client; const client = $appState.currentRequest.client;
const m = client.exe?.match(/\/([^/]+?$)|\\([^\\]+?$)/); const m = client.exe?.match(/\/([^/]+?$)|\\([^\\]+?$)/);
const appName = m[1] || m[2]; const appName = m ? m[1] || m[2] : '';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@ -26,6 +26,12 @@
}; };
dispatch('response'); dispatch('response');
} }
const actionDescriptions = {
Access: 'access your',
Delete: 'delete your',
Save: 'create new',
};
</script> </script>
@ -34,7 +40,7 @@
<div> <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> <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> <span>
WARNING: This application is requesting your base 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. These credentials are less secure than session credentials, since they don't expire automatically.
</span> </span>
</div> </div>
@ -51,6 +57,8 @@
{/if} {/if}
{:else if $appState.currentRequest.type === 'Ssh'} {:else if $appState.currentRequest.type === 'Ssh'}
{appName ? `"${appName}"` : 'An application'} would like to use your SSH key "{$appState.currentRequest.key_name}". {appName ? `"${appName}"` : 'An application'} would like to use your SSH key "{$appState.currentRequest.key_name}".
{:else if $appState.currentRequest.type === 'Docker'}
{appName ? `"${appName}"` : 'An application'} would like to {actionDescriptions[$appState.currentRequest.action]} Docker credentials for <code>{$appState.currentRequest.server_url}</code>.
{/if} {/if}
</h2> </h2>
@ -59,6 +67,8 @@
<code class="">{@html client.exe ? breakPath(client.exe) : 'Unknown'}</code> <code class="">{@html client.exe ? breakPath(client.exe) : 'Unknown'}</code>
<div class="text-right">PID:</div> <div class="text-right">PID:</div>
<code>{client.pid}</code> <code>{client.pid}</code>
<div class="text-right">User:</div>
<code>{client.username ?? 'Unknown'}</code>
</div> </div>
</div> </div>

View File

@ -5,20 +5,19 @@
import ErrorAlert from '../../ui/ErrorAlert.svelte'; import ErrorAlert from '../../ui/ErrorAlert.svelte';
import Icon from '../../ui/Icon.svelte'; import Icon from '../../ui/Icon.svelte';
import PassphraseInput from '../../ui/PassphraseInput.svelte';
export let record; export let record;
export let defaults; export let defaults;
import PassphraseInput from '../../ui/PassphraseInput.svelte';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
let showDetails = record.isNew ? true : false; let showDetails = record.isNew ? true : false;
let local = JSON.parse(JSON.stringify(record)); let local = JSON.parse(JSON.stringify(record));
$: isModified = JSON.stringify(local) !== JSON.stringify(record); $: isModified = JSON.stringify(local) !== JSON.stringify(record);
// explicitly subscribe to updates to `default`, so that we can update // explicitly subscribe to updates to `default`, so that we can update
// our local copy even if the component hasn't been recreated // our local copy even if the component hasn't been recreated
// (sadly we can't use a reactive binding because reasons I guess) // (sadly we can't use a reactive binding because reasons I guess)
@ -31,7 +30,7 @@
showDetails = false; showDetails = false;
} }
</script> </script>

View File

@ -26,9 +26,12 @@
if (record.credential.type === 'AwsBase') { if (record.credential.type === 'AwsBase') {
return 'AWS credential'; return 'AWS credential';
} }
if (record.credential.type === 'Ssh') { else if (record.credential.type === 'Ssh') {
return 'SSH key'; return 'SSH key';
} }
else {
return `${record.credential.type} credential`;
}
} }
</script> </script>

View File

@ -0,0 +1,112 @@
<script>
import { createEventDispatcher } from 'svelte';
import { fade, slide } from 'svelte/transition';
import { invoke } from '@tauri-apps/api/core';
import ErrorAlert from '../../ui/ErrorAlert.svelte';
import Icon from '../../ui/Icon.svelte';
import PassphraseInput from '../../ui/PassphraseInput.svelte';
export let record;
let local = JSON.parse(JSON.stringify(record));
$: isModified = JSON.stringify(local) !== JSON.stringify(record);
let showDetails = record?.isNew;
let alert;
const dispatch = createEventDispatcher();
async function saveCredential() {
await invoke('save_credential', {record: local});
dispatch('save', local);
showDetails = false;
}
</script>
<div class="rounded-box space-y-4 bg-base-200">
<div class="flex items-center px-6 py-4 gap-x-4">
{#if !record.isNew}
{#if showDetails}
<input
type="text"
class="input input-bordered bg-transparent text-lg font-bold grow"
bind:value={local.name}
>
{:else}
<h3 class="text-lg font-bold break-all">
{record.name}
</h3>
{/if}
{/if}
<div class="join ml-auto">
<button
type="button"
class="btn btn-outline join-item"
on:click={() => showDetails = !showDetails}
>
<Icon name="pencil" class="size-6" />
</button>
<button
type="button"
class="btn btn-outline btn-error join-item"
on:click={() => dispatch('delete', record)}
>
<Icon name="trash" class="size-6" />
</button>
</div>
</div>
{#if showDetails}
<form
transition:slide|local={{duration: 200}}
class=" px-6 pb-4 space-y-4"
on:submit|preventDefault={() => alert.run(saveCredential)}
>
<ErrorAlert bind:this={alert} />
<div class="grid grid-cols-[auto_1fr] items-center gap-4">
{#if record.isNew}
<span class="justify-self-end">Name</span>
<input
type="text"
class="input input-bordered bg-transparent"
bind:value={local.name}
>
{/if}
<span class="justify-self-end">Server URL</span>
<input
type="text"
class="input input-bordered font-mono bg-transparent"
bind:value={local.credential.ServerURL}
>
<span class="justify-self-end">Username</span>
<input
type="text"
class="input input-bordered font-mono bg-transparent"
bind:value={local.credential.Username}
>
<span>Password</span>
<div class="font-mono">
<PassphraseInput class="bg-transparent" bind:value={local.credential.Secret} />
</div>
</div>
<div class="flex justify-end">
{#if isModified}
<button
transition:fade={{duration: 100}}
type="submit"
class="btn btn-primary"
>
Save
</button>
{/if}
</div>
</form>
{/if}
</div>

View File

@ -14,6 +14,7 @@
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
let showPassphrase = false;
let alert; let alert;
let saving = false; let saving = false;
let passphrase = ''; let passphrase = '';
@ -52,7 +53,6 @@
try { try {
await alert.run(async () => { await alert.run(async () => {
await invoke('set_passphrase', {passphrase}) await invoke('set_passphrase', {passphrase})
throw('something bad happened');
$appState.sessionStatus = 'unlocked'; $appState.sessionStatus = 'unlocked';
dispatch('save'); dispatch('save');
}); });
@ -73,6 +73,7 @@
</div> </div>
<PassphraseInput <PassphraseInput
bind:value={passphrase} bind:value={passphrase}
bind:show={showPassphrase}
on:input={onInput} on:input={onInput}
placeholder="correct horse battery staple" placeholder="correct horse battery staple"
/> />
@ -84,6 +85,7 @@
</div> </div>
<PassphraseInput <PassphraseInput
bind:value={confirmPassphrase} bind:value={confirmPassphrase}
bind:show={showPassphrase}
on:input={onInput} on:change={onChange} on:input={onInput} on:change={onChange}
placeholder="correct horse battery staple" placeholder="correct horse battery staple"
/> />