split into workspace so CLI can be a standalone crate

This commit is contained in:
Joseph Montanaro 2024-07-15 10:34:51 -04:00
parent 27c2f467c4
commit 55801384eb
12 changed files with 259 additions and 148 deletions

19
src-tauri/Cargo.lock generated
View File

@ -1204,9 +1204,8 @@ dependencies = [
"aws-sdk-sts",
"aws-smithy-types",
"aws-types",
"base64 0.22.1",
"chacha20poly1305",
"clap",
"creddy_cli",
"dirs 5.0.1",
"futures",
"is-terminal",
@ -1241,6 +1240,18 @@ dependencies = [
"windows 0.51.1",
]
[[package]]
name = "creddy_cli"
version = "0.5.3"
dependencies = [
"anyhow",
"clap",
"dirs 5.0.1",
"serde",
"serde_json",
"tokio",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.13"
@ -4619,9 +4630,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.118"
version = "1.0.120"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d947f6b3163d8857ea16c4fa0dd4840d52f3041039a85decd46867eb1abef2e4"
checksum = "4e0d21c9a8cae1235ad58a00c11cb40d4b1e5c784f1ef2c537876ed6ffd8b7c5"
dependencies = [
"itoa 1.0.11",
"ryu",

View File

@ -9,37 +9,40 @@ default-run = "creddy"
edition = "2021"
rust-version = "1.57"
[[bin]]
name = "creddy_cli"
path = "src/bin/creddy_cli.rs"
[[bin]]
name = "creddy"
path = "src/main.rs"
# we use a workspace so that we can split out the CLI and make it possible to build independently
[workspace]
members = ["creddy_cli"]
[workspace.dependencies]
dirs = "5.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = ">=1.19", features = ["full"] }
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "2.0.0-beta", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
creddy_cli = { path = "./creddy_cli" }
tauri = { version = "2.0.0-beta", features = ["tray-icon"] }
sodiumoxide = "0.2.7"
tokio = { version = ">=1.19", features = ["full"] }
sysinfo = "0.26.8"
aws-config = "1.5.3"
aws-types = "1.3.2"
aws-sdk-sts = "1.33.0"
aws-smithy-types = "1.2.0"
dirs = { workspace = true }
thiserror = "1.0.38"
once_cell = "1.16.0"
strum = "0.24"
strum_macros = "0.24"
auto-launch = "0.4.0"
dirs = "5.0"
clap = { version = "3.2.23", features = ["derive"] }
is-terminal = "0.4.7"
argon2 = { version = "0.5.0", features = ["std"] }
chacha20poly1305 = { version = "0.10.1", features = ["std"] }
@ -55,7 +58,10 @@ ssh-agent-lib = "0.4.0"
ssh-key = { version = "0.6.6", features = ["rsa", "ed25519", "encryption"] }
signature = "2.2.0"
tokio-stream = "0.1.15"
serde = { workspace = true }
serde_json = { workspace = true }
sqlx = { version = "0.7.4", features = ["sqlite", "runtime-tokio", "uuid"] }
tokio = { workspace = true }
tokio-util = { version = "0.7.11", features = ["codec"] }
futures = "0.3.30"
openssl = "0.10.64"
@ -71,8 +77,5 @@ default = ["custom-protocol"]
# DO NOT remove this
custom-protocol = ["tauri/custom-protocol"]
[dev-dependencies]
base64 = "0.22.1"
# [profile.dev.build-override]
# opt-level = 3

View File

@ -0,0 +1,12 @@
[package]
name = "creddy_cli"
version = "0.5.3"
edition = "2021"
[dependencies]
anyhow = "1.0.86"
clap = { version = "3.2.23", features = ["derive"] }
dirs = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }

View File

@ -1,9 +1,12 @@
use std::ffi::OsString;
use std::env;
use std::path::PathBuf;
use std::process::Command as ChildCommand;
#[cfg(unix)]
use std::os::unix::process::CommandExt;
#[cfg(windows)]
use std::time::Duration;
use anyhow::{bail, Context};
use clap::{
Command,
Arg,
@ -11,34 +14,22 @@ use clap::{
ArgAction,
builder::PossibleValuesParser,
value_parser,
};
};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use crate::errors::*;
use crate::srv::{
self,
Request,
Response
};
use crate::shortcuts::ShortcutAction;
#[cfg(unix)]
use {
std::os::unix::process::CommandExt,
tokio::net::UnixStream,
};
#[cfg(windows)]
use {
tokio::net::windows::named_pipe::{NamedPipeClient, ClientOptions},
windows::Win32::Foundation::ERROR_PIPE_BUSY,
use crate::proto::{
CliCredential,
CliRequest,
CliResponse,
ServerError,
ShortcutAction,
};
pub fn parser() -> Command<'static> {
Command::new("creddy")
.version(env!("CARGO_PKG_VERSION"))
.about("A friendly AWS credentials manager")
.about("A friendly credential manager")
.arg(
Arg::new("server_addr")
.short('a')
@ -102,56 +93,53 @@ pub fn parser() -> Command<'static> {
}
pub fn get(args: &ArgMatches, global_args: &ArgMatches) -> Result<(), CliError> {
pub fn get(args: &ArgMatches, global_args: &ArgMatches) -> anyhow::Result<()> {
let name = args.get_one("name").cloned();
let base = *args.get_one("base").unwrap_or(&false);
let addr = global_args.get_one("server_addr").cloned();
let output = match make_request(addr, &Request::GetAwsCredentials { name, base })? {
Response::AwsBase(creds) => serde_json::to_string(&creds).unwrap(),
Response::AwsSession(creds) => serde_json::to_string(&creds).unwrap(),
r => return Err(RequestError::Unexpected(r).into()),
let output = match make_request(addr, &CliRequest::GetCredential { name, base })?? {
CliResponse::Credential(c) => serde_json::to_string_pretty(&c).unwrap(),
r => bail!("Unexpected response from server: {r}"),
};
println!("{output}");
Ok(())
}
pub fn exec(args: &ArgMatches, global_args: &ArgMatches) -> Result<(), CliError> {
pub fn exec(args: &ArgMatches, global_args: &ArgMatches) -> anyhow::Result<()> {
let name = args.get_one("name").cloned();
let base = *args.get_one("base").unwrap_or(&false);
let addr = global_args.get_one("server_addr").cloned();
let mut cmd_line = args.get_many("command")
.ok_or(ExecError::NoCommand)?;
let cmd_name: &String = cmd_line.next().unwrap(); // Clap guarantees that there will be at least one
// Clap guarantees that cmd_line will be a sequence of at least 1 item
// test this!
let mut cmd_line = args.get_many("command").unwrap();
let cmd_name: &String = cmd_line.next().unwrap();
let mut cmd = ChildCommand::new(cmd_name);
cmd.args(cmd_line);
match make_request(addr, &Request::GetAwsCredentials { name, base })? {
Response::AwsBase(creds) => {
match make_request(addr, &CliRequest::GetCredential { name, base })?? {
CliResponse::Credential(CliCredential::AwsBase(creds)) => {
cmd.env("AWS_ACCESS_KEY_ID", creds.access_key_id);
cmd.env("AWS_SECRET_ACCESS_KEY", creds.secret_access_key);
},
Response::AwsSession(creds) => {
CliResponse::Credential(CliCredential::AwsSession(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);
},
r => return Err(RequestError::Unexpected(r).into()),
r => bail!("Unexpected response from server: {r}"),
}
#[cfg(unix)]
{
// cmd.exec() never returns if successful
let e = cmd.exec();
match e.kind() {
std::io::ErrorKind::NotFound => {
let name: OsString = cmd_name.into();
Err(ExecError::NotFound(name).into())
}
_ => Err(ExecError::ExecutionFailed(e).into()),
}
Err(e).with_context(|| {
// eventually figure out how to display the actual command
format!("Failed to execute command")
})?;
Ok(())
}
#[cfg(windows)]
@ -172,7 +160,7 @@ pub fn exec(args: &ArgMatches, global_args: &ArgMatches) -> Result<(), CliError>
}
pub fn invoke_shortcut(args: &ArgMatches, global_args: &ArgMatches) -> Result<(), CliError> {
pub fn invoke_shortcut(args: &ArgMatches, global_args: &ArgMatches) -> anyhow::Result<()> {
let addr = global_args.get_one("server_addr").cloned();
let action = match args.get_one::<String>("action").map(|s| s.as_str()) {
Some("show_window") => ShortcutAction::ShowWindow,
@ -180,48 +168,32 @@ pub fn invoke_shortcut(args: &ArgMatches, global_args: &ArgMatches) -> Result<()
Some(&_) | None => unreachable!("Unknown shortcut action"), // guaranteed by clap
};
let req = Request::InvokeShortcut(action);
match make_request(addr, &req) {
Ok(Response::Empty) => Ok(()),
Ok(r) => Err(RequestError::Unexpected(r).into()),
Err(e) => Err(e.into()),
let req = CliRequest::InvokeShortcut(action);
match make_request(addr, &req)?? {
CliResponse::Empty => Ok(()),
r => bail!("Unexpected response from server: {r}"),
}
}
// Explanation for double-result: the server will return a (serialized) Result
// to indicate when the operation succeeded or failed, which we deserialize.
// However, the operation may fail to even communicate with the server, in
// which case we return the outer Result
#[tokio::main]
async fn make_request(addr: Option<PathBuf>, req: &Request) -> Result<Response, RequestError> {
async fn make_request(
addr: Option<PathBuf>,
req: &CliRequest
) -> anyhow::Result<Result<CliResponse, ServerError>> {
let mut data = serde_json::to_string(req).unwrap();
// server expects newline marking end of request
data.push('\n');
let mut stream = connect(addr).await?;
let mut stream = crate::connect(addr).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(addr: Option<PathBuf>) -> Result<NamedPipeClient, std::io::Error> {
// apparently attempting to connect can fail if there's already a client connected
loop {
let addr = addr.unwrap_or_else(|| srv::addr("creddy-server"));
match ClientOptions::new().open(&addr) {
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(addr: Option<PathBuf>) -> Result<UnixStream, std::io::Error> {
let path = addr.unwrap_or_else(|| srv::addr("creddy-server"));
UnixStream::connect(&path).await
let res: Result<CliResponse, ServerError> = serde_json::from_slice(&buf)?;
Ok(res)
}

View File

@ -0,0 +1,39 @@
mod cli;
pub use cli::{
exec,
get,
parser,
invoke_shortcut,
};
pub(crate) use platform::connect;
pub use platform::server_addr;
mod proto;
#[cfg(unix)]
mod platform {
use std::path::PathBuf;
use tokio::net::UnixStream;
pub async fn connect(addr: Option<PathBuf>) -> Result<UnixStream, std::io::Error> {
let path = addr.unwrap_or_else(|| server_addr("creddy-server"));
UnixStream::connect(&path).await
}
pub fn server_addr(sock_name: &str) -> PathBuf {
let mut path = dirs::runtime_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"));
path.push(format!("{sock_name}.sock"));
path
}
}
#[cfg(windows)]
mod platform {
pub fn server_addr(sock_name: &str) -> String {
format!(r"\\.\pipe\{sock_name}")
}
}

View File

@ -1,22 +1,14 @@
// Windows isn't really amenable to having a single executable work as both a CLI and GUI app,
// so we just have a second binary for CLI usage
use creddy::{
cli,
errors::CliError,
};
use std::{
env,
process::{self, Command},
};
use std::env;
use std::process::{self, Command};
fn main() {
let global_matches = cli::parser().get_matches();
let global_matches = creddy_cli::parser().get_matches();
let res = match global_matches.subcommand() {
None | Some(("run", _)) => launch_gui(),
Some(("get", m)) => cli::get(m, &global_matches),
Some(("exec", m)) => cli::exec(m, &global_matches),
Some(("shortcut", m)) => cli::invoke_shortcut(m, &global_matches),
Some(("get", m)) => creddy_cli::get(m, &global_matches),
Some(("exec", m)) => creddy_cli::exec(m, &global_matches),
Some(("shortcut", m)) => creddy_cli::invoke_shortcut(m, &global_matches),
_ => unreachable!("Unknown subcommand"),
};
@ -27,7 +19,7 @@ fn main() {
}
fn launch_gui() -> Result<(), CliError> {
fn launch_gui() -> anyhow::Result<()> {
let mut path = env::current_exe()?;
path.pop(); // bin dir

View File

@ -0,0 +1,90 @@
use std::fmt::{
Display,
Formatter,
Error as FmtError
};
use serde::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize)]
pub enum CliRequest {
GetCredential {
name: Option<String>,
base: bool,
},
InvokeShortcut(ShortcutAction),
}
#[derive(Debug, Serialize, Deserialize)]
pub enum ShortcutAction {
ShowWindow,
LaunchTerminal,
}
#[derive(Debug, Serialize, Deserialize)]
pub enum CliResponse {
Credential(CliCredential),
Empty,
}
impl Display for CliResponse {
fn fmt(&self, f: &mut Formatter) -> Result<(), FmtError> {
match self {
CliResponse::Credential(CliCredential::AwsBase(_)) => write!(f, "Credential (AwsBase)"),
CliResponse::Credential(CliCredential::AwsSession(_)) => write!(f, "Credential (AwsSession)"),
CliResponse::Empty => write!(f, "Empty"),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub enum CliCredential {
AwsBase(AwsBaseCredential),
AwsSession(AwsSessionCredential),
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct AwsBaseCredential {
#[serde(default = "default_aws_version")]
pub version: usize,
pub access_key_id: String,
pub secret_access_key: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct AwsSessionCredential {
#[serde(default = "default_aws_version")]
pub version: usize,
pub access_key_id: String,
pub secret_access_key: String,
pub session_token: String,
// we don't need to know the expiration for the CLI, so just use a string here
pub expiration: String,
}
fn default_aws_version() -> usize { 1 }
#[derive(Debug, Serialize, Deserialize)]
pub struct ServerError {
code: String,
msg: String,
}
impl Display for ServerError {
fn fmt(&self, f: &mut Formatter) -> Result<(), FmtError> {
write!(f, "Error response ({}) from server: {}", self.code, self.msg)?;
Ok(())
}
}
impl std::error::Error for ServerError {}

View File

@ -370,7 +370,7 @@ pub enum RequestError {
#[error("Error response from server: {0}")]
Server(ServerError),
#[error("Unexpected response from server")]
Unexpected(crate::srv::Response),
Unexpected(crate::srv::CliResponse),
#[error("The server did not respond with valid JSON")]
InvalidJson(#[from] serde_json::Error),
#[error("Error reading/writing stream: {0}")]

View File

@ -1,5 +1,4 @@
pub mod app;
pub mod cli;
mod config;
mod credentials;
pub mod errors;

View File

@ -5,21 +5,20 @@
use creddy::{
app,
cli,
errors::ShowError,
};
fn main() {
let global_matches = cli::parser().get_matches();
let global_matches = creddy_cli::parser().get_matches();
let res = match global_matches.subcommand() {
None | Some(("run", _)) => {
app::run().error_popup("Creddy encountered an error");
Ok(())
},
Some(("get", m)) => cli::get(m, &global_matches),
Some(("exec", m)) => cli::exec(m, &global_matches),
Some(("shortcut", m)) => cli::invoke_shortcut(m, &global_matches),
Some(("get", m)) => creddy_cli::get(m, &global_matches),
Some(("exec", m)) => creddy_cli::exec(m, &global_matches),
Some(("shortcut", m)) => creddy_cli::invoke_shortcut(m, &global_matches),
_ => unreachable!(),
};

View File

@ -9,8 +9,9 @@ use crate::shortcuts::{self, ShortcutAction};
use crate::state::AppState;
use super::{
CloseWaiter,
Request,
Response,
CliCredential,
CliRequest,
CliResponse,
Stream,
};
@ -43,13 +44,12 @@ async fn handle(
let waiter = CloseWaiter { stream: &mut stream };
let req: Request = serde_json::from_slice(&buf)?;
let req: CliRequest = serde_json::from_slice(&buf)?;
let res = match req {
Request::GetAwsCredentials { name, base } => get_aws_credentials(
CliRequest::GetCredential{ name, base } => get_aws_credentials(
name, base, client, app_handle, waiter
).await,
Request::InvokeShortcut(action) => invoke_shortcut(action).await,
Request::GetSshSignature(_) => return Err(HandlerError::Denied),
CliRequest::InvokeShortcut(action) => invoke_shortcut(action).await,
};
// doesn't make sense to send the error to the client if the client has already left
@ -63,9 +63,9 @@ async fn handle(
}
async fn invoke_shortcut(action: ShortcutAction) -> Result<Response, HandlerError> {
async fn invoke_shortcut(action: ShortcutAction) -> Result<CliResponse, HandlerError> {
shortcuts::exec_shortcut(action);
Ok(Response::Empty)
Ok(CliResponse::Empty)
}
@ -75,7 +75,7 @@ async fn get_aws_credentials(
client: Client,
app_handle: AppHandle,
mut waiter: CloseWaiter<'_>,
) -> Result<Response, HandlerError> {
) -> Result<CliResponse, HandlerError> {
let state = app_handle.state::<AppState>();
let rehide_ms = {
let config = state.config.read().await;
@ -108,11 +108,11 @@ async fn get_aws_credentials(
Approval::Approved => {
if response.base {
let creds = state.get_aws_base(name).await?;
Ok(Response::AwsBase(creds))
Ok(CliResponse::Credential(CliCredential::AwsBase(creds)))
}
else {
let creds = state.get_aws_session(name).await?;
Ok(Response::AwsSession(creds.clone()))
let creds = state.get_aws_session(name).await?.clone();
Ok(CliResponse::Credential(CliCredential::AwsSession(creds)))
}
},
Approval::Denied => Err(HandlerError::Denied),

View File

@ -6,7 +6,6 @@ use tauri::{
};
use tokio::io::AsyncReadExt;
use serde::{Serialize, Deserialize};
use ssh_agent_lib::proto::message::SignRequest;
use crate::credentials::{AwsBaseCredential, AwsSessionCredential};
use crate::errors::*;
@ -15,25 +14,32 @@ use crate::shortcuts::ShortcutAction;
pub mod creddy_server;
pub mod agent;
use platform::Stream;
pub use platform::addr;
// These types match what's defined in creddy_cli, but they are separate types
// 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
#[derive(Debug, Serialize, Deserialize)]
pub enum Request {
GetAwsCredentials {
pub enum CliRequest {
GetCredential {
name: Option<String>,
base: bool,
},
GetSshSignature(SignRequest),
InvokeShortcut(ShortcutAction),
}
#[derive(Debug, Serialize, Deserialize)]
pub enum Response {
pub enum CliResponse {
Credential(CliCredential),
Empty,
}
#[derive(Debug, Serialize, Deserialize)]
pub enum CliCredential {
AwsBase(AwsBaseCredential),
AwsSession(AwsSessionCredential),
Empty,
}
@ -92,7 +98,7 @@ mod platform {
pub type Stream = UnixStream;
pub fn bind(sock_name: &str) -> std::io::Result<(UnixListener, PathBuf)> {
let path = addr(sock_name);
let path = creddy_cli::server_addr(sock_name);
match std::fs::remove_file(&path) {
Ok(_) => (),
Err(e) if e.kind() == ErrorKind::NotFound => (),
@ -112,14 +118,6 @@ mod platform {
Ok((stream, pid))
}
pub fn addr(sock_name: &str) -> PathBuf {
let mut path = dirs::runtime_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"));
path.push(format!("{sock_name}.sock"));
path
}
}
@ -140,7 +138,7 @@ mod platform {
pub type Stream = NamedPipeServer;
pub fn bind(sock_name: &str) -> std::io::Result<(String, NamedPipeServer)> {
let addr = addr(sock_name);
let addr = creddy_cli::server_addr(sock_name);
let listener = ServerOptions::new()
.first_pipe_instance(true)
.create(&addr)?;
@ -163,8 +161,4 @@ mod platform {
unsafe { GetNamedPipeClientProcessId(handle, &mut pid as *mut u32)? };
Ok((stream, pid))
}
pub fn addr(sock_name: &str) -> String {
format!(r"\\.\pipe\{sock_name}")
}
}