working implementation of docker get

This commit is contained in:
Joseph Montanaro 2024-09-21 05:30:25 -04:00
parent e4a7c62828
commit c7a7b45468
12 changed files with 233 additions and 116 deletions

View File

@ -1,4 +1,4 @@
use std::io; use std::io::{self, Read};
use anyhow::bail; use anyhow::bail;
@ -12,7 +12,6 @@ use super::{
pub fn docker_store(global_args: GlobalArgs) -> 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::SaveCredential { let req = CliRequest::SaveCredential {
name: input.username.clone(), name: input.username.clone(),
@ -25,3 +24,20 @@ pub fn docker_store(global_args: GlobalArgs) -> anyhow::Result<()> {
r => bail!("Unexpected response from server: {r}"), 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(())
}

View File

@ -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,
}; };
@ -203,7 +203,7 @@ pub fn invoke_shortcut(args: InvokeArgs, global: GlobalArgs) -> anyhow::Result<(
pub fn docker_credential_helper(cmd: DockerCmd, global_args: GlobalArgs) -> 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(global_args), DockerCmd::Store => docker::docker_store(global_args),
DockerCmd::Erase => todo!(), DockerCmd::Erase => todo!(),
} }

View File

@ -10,10 +10,13 @@ use serde::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub enum CliRequest { pub enum CliRequest {
GetCredential { GetAwsCredential {
name: Option<String>, name: Option<String>,
base: bool, base: bool,
}, },
GetDockerCredential {
server_url: String,
},
SaveCredential { SaveCredential {
name: String, name: String,
is_default: bool, is_default: bool,

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.*

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

@ -16,7 +16,6 @@ use crate::terminal;
#[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 +24,46 @@ 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 client: Client,
Aws(AwsRequestNotification), pub server_url: String,
Ssh(SshRequestNotification),
} }
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(client: Client, server_url: String) -> Self {
Self::Docker(DockerRequestNotification {client, server_url})
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RequestNotification {
pub id: u64,
#[serde(flatten)]
pub detail: RequestNotificationDetail,
} }

View File

@ -11,7 +11,7 @@ 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, RequestNotification, RequestNotificationDetail};
use crate::state::AppState; use crate::state::AppState;
use super::{CloseWaiter, Stream}; use super::{CloseWaiter, Stream};
@ -40,7 +40,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 +69,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,16 @@
use sqlx::types::uuid::Uuid;
use tauri::{AppHandle, Manager}; use tauri::{AppHandle, Manager};
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::oneshot; use tokio::sync::oneshot;
use crate::clientinfo::{self, Client}; use crate::clientinfo::{self, Client};
use crate::credentials::{
Credential,
CredentialRecord,
Crypto
};
use crate::errors::*; use crate::errors::*;
use crate::ipc::{Approval, RequestNotification}; use crate::ipc::{Approval, AwsRequestNotification, RequestNotificationDetail, RequestResponse};
use crate::shortcuts::{self, ShortcutAction}; use crate::shortcuts::{self, ShortcutAction};
use crate::state::AppState; use crate::state::AppState;
use super::{ use super::{
@ -46,9 +52,15 @@ 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::GetDockerCredential{ server_url } => get_docker_credentials (
server_url, client, app_handle, waiter
).await,
CliRequest::SaveCredential{ name, is_default, credential } => save_credential(
name, is_default, credential, app_handle
).await,
CliRequest::InvokeShortcut(action) => invoke_shortcut(action).await, CliRequest::InvokeShortcut(action) => invoke_shortcut(action).await,
}; };
@ -74,59 +86,64 @@ 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_credentials(
server_url: String,
client: Client,
app_handle: AppHandle,
waiter: CloseWaiter<'_>,
) -> Result<CliResponse, HandlerError> {
let detail = RequestNotificationDetail::new_docker(client, server_url.clone());
let response = super::send_credentials_request(detail, app_handle.clone(), waiter).await?;
match response.approval {
Approval::Approved => {
let state = app_handle.state::<AppState>();
let creds = state.get_docker_credential(&server_url).await?;
Ok(CliResponse::Credential(CliCredential::Docker(creds)))
},
Approval::Denied => {
Err(HandlerError::Denied)
},
}
}
pub async fn save_credential(
name: String,
is_default: bool,
credential: Credential,
app_handle: AppHandle,
) -> Result<CliResponse, HandlerError> { ) -> Result<CliResponse, HandlerError> {
let state = app_handle.state::<AppState>(); let state = app_handle.state::<AppState>();
let rehide_ms = {
let config = state.config.read().await; // eventually ask the frontend to unlock here
config.rehide_ms
// a bit weird but convenient
let random_bytes = Crypto::salt();
let id = Uuid::from_slice(&random_bytes[..16]).unwrap();
let record = CredentialRecord {
id, name, is_default, credential
}; };
let lease = state.acquire_visibility_lease(rehide_ms).await state.save_credential(record).await?;
.map_err(|_e| HandlerError::NoMainWindow)?; // automate this conversion eventually?
let (chan_send, chan_recv) = oneshot::channel(); Ok(CliResponse::Empty)
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! {
r = chan_recv => r?,
_ = waiter.wait_for_close() => {
app_handle.emit("request-cancelled", request_id)?;
return Err(HandlerError::Abandoned);
},
};
match response.approval {
Approval::Approved => {
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)
},
};
lease.release();
result
} }

View File

@ -3,13 +3,23 @@ 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::clientinfo::Client;
use crate::credentials::{
AwsBaseCredential,
AwsSessionCredential,
Credential,
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;
@ -21,10 +31,18 @@ use platform::Stream;
// 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)]
pub enum CliRequest { pub enum CliRequest {
GetCredential { GetAwsCredential {
name: Option<String>, name: Option<String>,
base: bool, base: bool,
}, },
GetDockerCredential {
server_url: String,
},
SaveCredential {
name: String,
is_default: bool,
credential: Credential,
},
InvokeShortcut(ShortcutAction), InvokeShortcut(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};
@ -193,7 +194,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 +323,13 @@ impl AppState {
Ok(k) Ok(k)
} }
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

@ -34,7 +34,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 +51,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 use your Docker credentials for <code>{$appState.currentRequest.server_url}</code>.
{/if} {/if}
</h2> </h2>