Compare commits

...

10 Commits

29 changed files with 849 additions and 136 deletions

View File

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

4
src-tauri/Cargo.lock generated
View File

@ -1241,7 +1241,7 @@ dependencies = [
[[package]] [[package]]
name = "creddy" name = "creddy"
version = "0.5.4" version = "0.6.0"
dependencies = [ dependencies = [
"argon2", "argon2",
"auto-launch", "auto-launch",
@ -1287,7 +1287,7 @@ dependencies = [
[[package]] [[package]]
name = "creddy_cli" name = "creddy_cli"
version = "0.5.4" version = "0.6.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",

View File

@ -1,6 +1,6 @@
[package] [package]
name = "creddy" name = "creddy"
version = "0.5.4" version = "0.6.0"
description = "A friendly AWS credentials manager" description = "A friendly AWS credentials manager"
authors = ["Joseph Montanaro"] authors = ["Joseph Montanaro"]
license = "" license = ""

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

@ -0,0 +1,53 @@
use std::io::{self, Read};
use anyhow::bail;
use crate::proto::{CliResponse, DockerCredential};
use super::{
CliCredential,
CliRequest,
GlobalArgs
};
pub fn docker_store(global_args: GlobalArgs) -> anyhow::Result<()> {
let input: DockerCredential = serde_json::from_reader(io::stdin())?;
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()
};
match super::make_request(global_args.server_addr, &req)?? {
CliResponse::Credential(CliCredential::Docker(d)) => {
println!("{}", serde_json::to_string(&d)?);
},
r => bail!("Unexpected response from server: {r}"),
}
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

@ -22,6 +22,8 @@ use crate::proto::{
ShortcutAction, ShortcutAction,
}; };
mod docker;
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
#[command( #[command(
@ -70,6 +72,9 @@ pub enum Action {
Exec(ExecArgs), Exec(ExecArgs),
/// Invoke an action normally triggered by hotkey (e.g. launch terminal) /// Invoke an action normally triggered by hotkey (e.g. launch terminal)
Shortcut(InvokeArgs), Shortcut(InvokeArgs),
/// Interact with Docker credentials via the docker-credential-helper protocol
#[command(subcommand)]
Docker(DockerCmd),
} }
@ -101,8 +106,19 @@ pub struct InvokeArgs {
} }
#[derive(Debug, Subcommand)]
pub enum DockerCmd {
/// Get a stored Docker credential
Get,
/// Store a new Docker credential
Store,
/// Remove a stored Docker credential
Erase,
}
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,
}; };
@ -129,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,
}; };
@ -177,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}"),
@ -185,10 +201,20 @@ pub fn invoke_shortcut(args: InvokeArgs, global: GlobalArgs) -> anyhow::Result<(
} }
pub fn docker_credential_helper(cmd: DockerCmd, global_args: GlobalArgs) -> anyhow::Result<()> {
match cmd {
DockerCmd::Get => docker::docker_get(global_args),
DockerCmd::Store => docker::docker_store(global_args),
DockerCmd::Erase => docker::docker_erase(global_args),
}
}
// Explanation for double-result: the server will return a (serialized) Result // Explanation for double-result: the server will return a (serialized) 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

@ -5,6 +5,7 @@ pub use cli::{
exec, exec,
get, get,
invoke_shortcut, invoke_shortcut,
docker_credential_helper,
}; };
pub(crate) use platform::connect; pub(crate) use platform::connect;

View File

@ -11,6 +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, cli.global_args),
}; };
if let Err(e) = res { if let Err(e) = res {

View File

@ -9,12 +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,
}, },
InvokeShortcut(ShortcutAction), GetDockerCredential {
server_url: String,
},
StoreDockerCredential(DockerCredential),
EraseDockerCredential {
server_url: String,
},
InvokeShortcut{
action: ShortcutAction,
},
} }
@ -36,6 +46,7 @@ impl Display for CliResponse {
match self { match self {
CliResponse::Credential(CliCredential::AwsBase(_)) => write!(f, "Credential (AwsBase)"), CliResponse::Credential(CliCredential::AwsBase(_)) => write!(f, "Credential (AwsBase)"),
CliResponse::Credential(CliCredential::AwsSession(_)) => write!(f, "Credential (AwsSession)"), CliResponse::Credential(CliCredential::AwsSession(_)) => write!(f, "Credential (AwsSession)"),
CliResponse::Credential(CliCredential::Docker(_)) => write!(f, "Credential (Docker)"),
CliResponse::Empty => write!(f, "Empty"), CliResponse::Empty => write!(f, "Empty"),
} }
} }
@ -46,6 +57,7 @@ impl Display for CliResponse {
pub enum CliCredential { pub enum CliCredential {
AwsBase(AwsBaseCredential), AwsBase(AwsBaseCredential),
AwsSession(AwsSessionCredential), AwsSession(AwsSessionCredential),
Docker(DockerCredential),
} }
@ -75,6 +87,16 @@ pub struct AwsSessionCredential {
fn default_aws_version() -> usize { 1 } fn default_aws_version() -> usize { 1 }
#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct DockerCredential {
#[serde(rename = "ServerURL")]
pub server_url: String,
pub username: String,
pub secret: String,
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct ServerError { pub struct ServerError {
code: String, code: String,

View File

@ -0,0 +1,12 @@
CREATE TABLE docker_credentials (
id BLOB UNIQUE NOT NULL,
-- The Docker credential helper protocol only sends the server_url, so
-- we should guarantee that we will only ever have one matching credential.
-- Also, it's easier to go from unique -> not-unique than vice versa if we
-- decide that's necessary in the future
server_url TEXT UNIQUE NOT NULL,
username TEXT NOT NULL,
secret_enc BLOB NOT NULL,
nonce BLOB NOT NULL,
FOREIGN KEY(id) REFERENCES credentials(id) ON DELETE CASCADE
);

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

@ -0,0 +1,196 @@
use chacha20poly1305::XNonce;
use serde::{Serialize, Deserialize};
use sqlx::{
FromRow,
Sqlite,
Transaction,
types::Uuid,
};
use super::{Credential, Crypto, PersistentCredential};
use crate::errors::*;
#[derive(Debug, Clone, FromRow)]
pub struct DockerRow {
id: Uuid,
server_url: String,
username: String,
secret_enc: Vec<u8>,
nonce: Vec<u8>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct DockerCredential {
#[serde(rename = "ServerURL")]
pub server_url: String,
pub username: String,
pub secret: String,
}
impl PersistentCredential for DockerCredential {
type Row = DockerRow;
fn type_name() -> &'static str { "docker" }
fn into_credential(self) -> Credential { Credential::Docker(self) }
fn row_id(row: &DockerRow) -> Uuid { row.id }
fn from_row(row: DockerRow, crypto: &Crypto) -> Result<Self, LoadCredentialsError> {
let nonce = XNonce::clone_from_slice(&row.nonce);
let secret_bytes = crypto.decrypt(&nonce, &row.secret_enc)?;
let secret = String::from_utf8(secret_bytes)
.map_err(|_| LoadCredentialsError::InvalidData)?;
Ok(DockerCredential {
server_url: row.server_url,
username: row.username,
secret
})
}
async fn save_details(&self, id: &Uuid, crypto: &Crypto, txn: &mut Transaction<'_, Sqlite>) -> Result<(), SaveCredentialsError> {
let (nonce, ciphertext) = crypto.encrypt(self.secret.as_bytes())?;
let nonce_bytes = &nonce.as_slice();
sqlx::query!(
"INSERT OR REPLACE INTO docker_credentials (
id,
server_url,
username,
secret_enc,
nonce
)
VALUES (?, ?, ?, ?, ?)",
id, self.server_url, self.username, ciphertext, nonce_bytes,
).execute(&mut **txn).await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::credentials::CredentialRecord;
use creddy_cli::proto::DockerCredential as CliDockerCredential;
use sqlx::SqlitePool;
use sqlx::types::uuid::uuid;
fn test_credential() -> DockerCredential {
DockerCredential {
server_url: "https://registry.jfmonty2.com".into(),
username: "joe@jfmonty2.com".into(),
secret: "correct horse battery staple".into(),
}
}
fn test_credential_2() -> DockerCredential {
DockerCredential {
server_url: "https://index.docker.io/v1".into(),
username: "test@example.com".into(),
secret: "a very secure passphrase".into(),
}
}
fn test_record() -> CredentialRecord {
CredentialRecord {
id: uuid!("00000000-0000-0000-0000-000000000000"),
name: "docker_test".into(),
is_default: false,
credential: Credential::Docker(test_credential()),
}
}
fn test_record_2() -> CredentialRecord {
CredentialRecord {
id: uuid!("ffffffff-ffff-ffff-ffff-ffffffffffff"),
name: "docker_test_2".into(),
is_default: false,
credential: Credential::Docker(test_credential_2()),
}
}
#[sqlx::test]
fn test_save(pool: SqlitePool) {
let crypt = Crypto::random();
test_record().save(&crypt, &pool).await
.expect("Failed to save record");
}
#[sqlx::test(fixtures("docker_credentials"))]
fn test_load(pool: SqlitePool) {
let crypt = Crypto::fixed();
let id = uuid!("00000000-0000-0000-0000-000000000000");
let loaded = DockerCredential::load(&id, &crypt, &pool).await
.expect("Failed to load record");
assert_eq!(test_credential(), loaded);
}
#[sqlx::test(fixtures("docker_credentials"))]
async fn test_overwrite(pool: SqlitePool) {
let crypt = Crypto::fixed();
let mut record = test_record_2();
// give it the same id as test_record so that it overwrites
let id = uuid!("00000000-0000-0000-0000-000000000000");
record.id = id;
record.save(&crypt, &pool).await
.expect("Failed to overwrite original record with second record");
let loaded = DockerCredential::load(&id, &crypt, &pool).await
.expect("Failed to load again after overwriting");
assert_eq!(test_credential_2(), loaded);
}
#[sqlx::test(fixtures("docker_credentials"))]
async fn test_list(pool: SqlitePool) {
let crypt = Crypto::fixed();
let records = CredentialRecord::list(&crypt, &pool).await
.expect("Failed to list credentials");
assert_eq!(test_record(), records[0]);
}
// make sure that CLI credentials and app credentials don't drift apart
#[test]
fn test_cli_to_app() {
let cli_creds = CliDockerCredential {
server_url: "https://registry.jfmonty2.com".into(),
username: "joe@jfmonty2.com".into(),
secret: "correct horse battery staple".into(),
};
let json = serde_json::to_string(&cli_creds).unwrap();
let computed: DockerCredential = serde_json::from_str(&json)
.expect("Failed to deserialize Docker credentials from CLI -> main app");
assert_eq!(test_credential(), computed);
}
#[test]
fn test_app_to_cli() {
let app_creds = test_credential();
let json = serde_json::to_string(&app_creds).unwrap();
let computed: CliDockerCredential = serde_json::from_str(&json)
.expect("Failed to deserialize Docker credentials from main app -> CLI");
let expected = CliDockerCredential {
server_url: "https://registry.jfmonty2.com".into(),
username: "joe@jfmonty2.com".into(),
secret: "correct horse battery staple".into(),
};
assert_eq!(expected, computed);
}
}

View File

@ -0,0 +1,11 @@
INSERT INTO credentials (id, name, credential_type, is_default, created_at)
VALUES (X'00000000000000000000000000000000', 'docker_test', 'docker', 0, 1726756380);
INSERT INTO docker_credentials (id, server_url, username, secret_enc, nonce)
VALUES (
X'00000000000000000000000000000000',
'https://registry.jfmonty2.com',
'joe@jfmonty2.com',
X'C0B36EE54539D4113A8F73E99FB96B2BF4D87E91F7C3B48256C07E83E3E7EC738888B2FDE2B4DB0BE48BEFDE',
X'C5F7F627BBE09A1BB275BE8D2390596C76143881A7766E60'
);

View File

@ -17,6 +17,9 @@ pub use aws::{AwsBaseCredential, AwsSessionCredential};
mod crypto; mod crypto;
pub use crypto::Crypto; pub use crypto::Crypto;
mod docker;
pub use docker::DockerCredential;
mod record; mod record;
pub use record::CredentialRecord; pub use record::CredentialRecord;
@ -32,6 +35,7 @@ pub use ssh::SshKey;
pub enum Credential { pub enum Credential {
AwsBase(AwsBaseCredential), AwsBase(AwsBaseCredential),
AwsSession(AwsSessionCredential), AwsSession(AwsSessionCredential),
Docker(DockerCredential),
Ssh(SshKey), Ssh(SshKey),
} }
@ -79,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.*
@ -99,15 +120,15 @@ pub trait PersistentCredential: for<'a> Deserialize<'a> + Sized {
async fn list(crypto: &Crypto, pool: &SqlitePool) -> Result<Vec<(Uuid, Credential)>, LoadCredentialsError> { async fn list(crypto: &Crypto, pool: &SqlitePool) -> Result<Vec<(Uuid, Credential)>, LoadCredentialsError> {
let q = format!( let q = format!(
"SELECT details.* "SELECT details.*
FROM FROM
{} details {} details
JOIN credentials c JOIN credentials c
ON c.id = details.id ON c.id = details.id
ORDER BY c.created_at", ORDER BY c.created_at",
Self::table_name(), Self::table_name(),
); );
let mut rows = sqlx::query_as::<_, Self::Row>(&q).fetch(pool); let mut rows = sqlx::query_as::<_, Self::Row>(&q).fetch(pool);
let mut creds = Vec::new(); let mut creds = Vec::new();
while let Some(row) = rows.try_next().await? { while let Some(row) = rows.try_next().await? {
let id = Self::row_id(&row); let id = Self::row_id(&row);
@ -118,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

@ -20,6 +20,7 @@ use super::{
AwsBaseCredential, AwsBaseCredential,
Credential, Credential,
Crypto, Crypto,
DockerCredential,
PersistentCredential, PersistentCredential,
SshKey, SshKey,
}; };
@ -51,6 +52,7 @@ impl CredentialRecord {
let type_name = match &self.credential { let type_name = match &self.credential {
Credential::AwsBase(_) => AwsBaseCredential::type_name(), Credential::AwsBase(_) => AwsBaseCredential::type_name(),
Credential::Ssh(_) => SshKey::type_name(), Credential::Ssh(_) => SshKey::type_name(),
Credential::Docker(_) => DockerCredential::type_name(),
_ => return Err(SaveCredentialsError::NotPersistent), _ => return Err(SaveCredentialsError::NotPersistent),
}; };
@ -86,6 +88,7 @@ impl CredentialRecord {
match &self.credential { match &self.credential {
Credential::AwsBase(b) => b.save_details(&self.id, crypto, &mut txn).await, Credential::AwsBase(b) => b.save_details(&self.id, crypto, &mut txn).await,
Credential::Ssh(s) => s.save_details(&self.id, crypto, &mut txn).await, Credential::Ssh(s) => s.save_details(&self.id, crypto, &mut txn).await,
Credential::Docker(d) => d.save_details(&self.id, crypto, &mut txn).await,
_ => Err(SaveCredentialsError::NotPersistent), _ => Err(SaveCredentialsError::NotPersistent),
}?; }?;
@ -167,6 +170,11 @@ impl CredentialRecord {
.ok_or(LoadCredentialsError::InvalidData)?; .ok_or(LoadCredentialsError::InvalidData)?;
records.push(Self::from_parts(parent, credential)); records.push(Self::from_parts(parent, credential));
} }
for (id, credential) in DockerCredential::list(crypto, pool).await? {
let parent = parent_map.remove(&id)
.ok_or(LoadCredentialsError::InvalidData)?;
records.push(Self::from_parts(parent, credential));
}
Ok(records) Ok(records)
} }

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

View File

@ -21,6 +21,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, 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};
@ -160,6 +161,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 +201,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)?;
@ -322,6 +330,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

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

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

@ -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>