Compare commits

..

22 Commits

Author SHA1 Message Date
Joseph Montanaro
41f8e8f2ab start working on exec subcommand 2023-05-02 15:36:00 -07:00
Joseph Montanaro
e8b8dc2976 cargo update 2023-05-02 15:24:46 -07:00
Joseph Montanaro
ddf865d0b4 switch to tokio RwLock instead of std 2023-05-02 15:24:35 -07:00
96bbc2dbc2 session renewal 2023-05-02 11:33:18 -07:00
161148d1f6 store base credentials as well as session credentials 2023-05-01 23:03:34 -07:00
760987f09b show approval errors in approval view 2023-05-01 16:53:24 -07:00
a75f34865e return to previous view after approval flow 2023-05-01 13:27:28 -07:00
886fcd9bb8 restrictive CSP and tauri allowlist 2023-05-01 09:05:46 -07:00
55775b6b05 move error dialog to trait 2023-04-30 14:10:21 -07:00
871dedf0a3 display setup errors 2023-04-30 10:52:46 -07:00
913148a75a minor tweaks 2023-04-29 10:01:45 -07:00
e746963052 change frontpage image and toast animation 2023-04-28 22:34:50 -07:00
b761d3b493 find data dir properly 2023-04-28 22:34:17 -07:00
c5dcc2e50a handle errors on config update 2023-04-28 14:33:23 -07:00
70d71ce14e restart listener when config changes 2023-04-28 14:33:04 -07:00
Joseph Montanaro
33a5600a30 prevent NumericSetting from accepting non-numeric inputs 2023-04-27 16:15:19 -07:00
Joseph Montanaro
741169d807 start on login 2023-04-27 14:24:08 -07:00
ebc00a5df6 confirm passphrase 2023-04-26 17:13:58 -07:00
c2cc007a81 display tweaks and approval page timing 2023-04-26 17:06:37 -07:00
4aab08e6f0 save settings to db 2023-04-26 15:49:08 -07:00
12d9d733a5 fix circular imports from routing 2023-04-26 13:05:51 -07:00
35271049dd settings page 2023-04-25 22:10:28 -07:00
32 changed files with 2220 additions and 2400 deletions

2284
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -17,7 +17,8 @@ tauri-build = { version = "1.0.4", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.0.5", features = ["api-all", "system-tray"] }
tauri = { version = "1.2", features = ["dialog", "os-all", "system-tray"] }
tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" }
sodiumoxide = "0.2.7"
tokio = { version = ">=1.19", features = ["full"] }
sqlx = { version = "0.6.2", features = ["sqlite", "runtime-tokio-rustls"] }
@ -31,14 +32,16 @@ thiserror = "1.0.38"
once_cell = "1.16.0"
strum = "0.24"
strum_macros = "0.24"
auto-launch = "0.4.0"
dirs = "5.0"
[features]
# by default Tauri runs in production mode
# when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL
default = [ "custom-protocol" ]
default = ["custom-protocol"]
# this feature is used used for production builds where `devPath` points to the filesystem
# DO NOT remove this
custom-protocol = [ "tauri/custom-protocol" ]
custom-protocol = ["tauri/custom-protocol"]
# [profile.dev.build-override]
# opt-level = 3

View File

@ -8,7 +8,7 @@ CREATE TABLE credentials (
);
CREATE TABLE config (
name TEXT NOT NULL,
name TEXT UNIQUE NOT NULL,
data TEXT NOT NULL
);

View File

@ -1,9 +1,13 @@
use netstat2::{AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo};
use tauri::Manager;
use sysinfo::{System, SystemExt, Pid, PidExt, ProcessExt};
use serde::{Serialize, Deserialize};
use crate::errors::*;
use crate::get_state;
use crate::{
errors::*,
config::AppConfig,
state::AppState,
};
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Hash)]
@ -13,13 +17,18 @@ pub struct Client {
}
fn get_associated_pids(local_port: u16) -> Result<Vec<u32>, netstat2::error::Error> {
async fn get_associated_pids(local_port: u16) -> Result<Vec<u32>, netstat2::error::Error> {
let state = crate::APP.get().unwrap().state::<AppState>();
let AppConfig {
listen_addr: app_listen_addr,
listen_port: app_listen_port,
..
} = *state.config.read().await;
let sockets_iter = netstat2::iterate_sockets_info(
AddressFamilyFlags::IPV4,
ProtocolFlags::TCP
)?;
get_state!(config as app_config);
for item in sockets_iter {
let sock_info = item?;
let proto_info = match sock_info.protocol_socket_info {
@ -28,9 +37,9 @@ fn get_associated_pids(local_port: u16) -> Result<Vec<u32>, netstat2::error::Err
};
if proto_info.local_port == local_port
&& proto_info.remote_port == app_config.listen_port
&& proto_info.local_addr == app_config.listen_addr
&& proto_info.remote_addr == app_config.listen_addr
&& proto_info.remote_port == app_listen_port
&& proto_info.local_addr == app_listen_addr
&& proto_info.remote_addr == app_listen_addr
{
return Ok(sock_info.associated_pids)
}
@ -40,10 +49,10 @@ fn get_associated_pids(local_port: u16) -> Result<Vec<u32>, netstat2::error::Err
// Theoretically, on some systems, multiple processes can share a socket
pub fn get_clients(local_port: u16) -> Result<Vec<Option<Client>>, ClientInfoError> {
pub async fn get_clients(local_port: u16) -> Result<Vec<Option<Client>>, ClientInfoError> {
let mut clients = Vec::new();
let mut sys = System::new();
for p in get_associated_pids(local_port)? {
for p in get_associated_pids(local_port).await? {
let pid = Pid::from_u32(p);
sys.refresh_process(pid);
let proc = sys.process(pid)

View File

@ -1,6 +1,7 @@
use std::net::Ipv4Addr;
use std::path::PathBuf;
use auto_launch::AutoLaunchBuilder;
use serde::{Serialize, Deserialize};
use sqlx::SqlitePool;
@ -17,6 +18,8 @@ pub struct AppConfig {
pub rehide_ms: u64,
#[serde(default = "default_start_minimized")]
pub start_minimized: bool,
#[serde(default = "default_start_on_login")]
pub start_on_login: bool,
}
@ -27,12 +30,14 @@ impl Default for AppConfig {
listen_port: default_listen_port(),
rehide_ms: default_rehide_ms(),
start_minimized: default_start_minimized(),
start_on_login: default_start_on_login(),
}
}
}
pub async fn load(pool: &SqlitePool) -> Result<AppConfig, SetupError> {
impl AppConfig {
pub async fn load(pool: &SqlitePool) -> Result<AppConfig, SetupError> {
let res = sqlx::query!("SELECT * from config where name = 'main'")
.fetch_optional(pool)
.await?;
@ -43,24 +48,60 @@ pub async fn load(pool: &SqlitePool) -> Result<AppConfig, SetupError> {
};
Ok(serde_json::from_str(&row.data)?)
}
pub async fn save(&self, pool: &SqlitePool) -> Result<(), sqlx::error::Error> {
let data = serde_json::to_string(self).unwrap();
sqlx::query(
"INSERT INTO config (name, data) VALUES ('main', ?)
ON CONFLICT (name) DO UPDATE SET data = ?"
)
.bind(&data)
.bind(&data)
.execute(pool)
.await?;
Ok(())
}
}
pub fn get_or_create_db_path() -> PathBuf {
if cfg!(debug_assertions) {
return PathBuf::from("./creddy.db");
pub fn set_auto_launch(is_configured: bool) -> Result<(), SetupError> {
let path_buf = std::env::current_exe()
.map_err(|e| auto_launch::Error::Io(e))?;
let path = path_buf
.to_string_lossy();
let auto = AutoLaunchBuilder::new()
.set_app_name("Creddy")
.set_app_path(&path)
.build()?;
let is_enabled = auto.is_enabled()?;
if is_configured && !is_enabled {
auto.enable()?;
}
else if !is_configured && is_enabled {
auto.disable()?;
}
let mut parent = std::env::var("HOME")
.map(|h| {
let mut p = PathBuf::from(h);
p.push(".config");
p
})
.unwrap_or(PathBuf::from("."));
Ok(())
}
parent.push("creddy.db");
parent
pub fn get_or_create_db_path() -> Result<PathBuf, DataDirError> {
// debug_assertions doesn't always mean we are running in dev
if cfg!(debug_assertions) && std::env::var("HOME").is_ok() {
return Ok(PathBuf::from("./creddy.db"));
}
let mut path = dirs::data_dir()
.ok_or(DataDirError::NotFound)?;
std::fs::create_dir_all(&path)?;
path.push("creddy.db");
Ok(path)
}
@ -74,7 +115,7 @@ fn default_listen_port() -> u16 {
}
fn default_listen_addr() -> Ipv4Addr { Ipv4Addr::LOCALHOST }
fn default_rehide_ms() -> u64 { 1000 }
fn default_start_minimized() -> bool { !cfg!(debug_assertions) } // default to start-minimized in production only
// start minimized and on login only in production mode
fn default_start_minimized() -> bool { !cfg!(debug_assertions) }
fn default_start_on_login() -> bool { !cfg!(debug_assertions) }

View File

@ -1,9 +1,9 @@
use std::error::Error;
use std::convert::AsRef;
use std::sync::mpsc;
use strum_macros::AsRefStr;
use thiserror::Error as ThisError;
use aws_sdk_sts::{
types::SdkError as AwsSdkError,
error::GetSessionTokenError,
@ -12,32 +12,29 @@ use sqlx::{
error::Error as SqlxError,
migrate::MigrateError,
};
use tauri::api::dialog::{
MessageDialogBuilder,
MessageDialogKind,
};
use serde::{Serialize, Serializer, ser::SerializeMap};
// pub struct SerializeError<E> {
// pub err: E,
// }
pub trait ErrorPopup {
fn error_popup(self, title: &str);
}
// impl<E: std::error::Error> Serialize for SerializeError<E>
// {
// fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
// let mut map = serializer.serialize_map(None)?;
// map.serialize_entry("msg", &format!("{}", self.err))?;
// if let Some(src) = self.err.source() {
// let ser_src = SerializeError { err: src };
// map.serialize_entry("source", &ser_src)?;
// }
// map.end()
// }
// }
impl<E: Error> ErrorPopup for Result<(), E> {
fn error_popup(self, title: &str) {
if let Err(e) = self {
let (tx, rx) = mpsc::channel();
MessageDialogBuilder::new(title, format!("{e}"))
.kind(MessageDialogKind::Error)
.show(move |_| tx.send(true).unwrap());
// impl<E: std::error::Error> From<E> for SerializeError<E> {
// fn from(err: E) -> Self {
// SerializeError { err }
// }
// }
rx.recv().unwrap();
}
}
}
fn serialize_basic_err<E, S>(err: &E, serializer: S) -> Result<S::Ok, S::Error>
@ -87,6 +84,21 @@ pub enum SetupError {
MigrationError(#[from] MigrateError),
#[error("Error parsing configuration from database")]
ConfigParseError(#[from] serde_json::Error),
#[error("Failed to set up start-on-login: {0}")]
AutoLaunchError(#[from] auto_launch::Error),
#[error("Failed to start listener: {0}")]
ServerSetupError(#[from] std::io::Error),
#[error("Failed to resolve data directory: {0}")]
DataDir(#[from] DataDirError),
}
#[derive(Debug, ThisError, AsRefStr)]
pub enum DataDirError {
#[error("Could not determine data directory")]
NotFound,
#[error("Failed to create data directory: {0}")]
Io(#[from] std::io::Error),
}
@ -94,9 +106,11 @@ pub enum SetupError {
#[derive(Debug, ThisError, AsRefStr)]
pub enum SendResponseError {
#[error("The specified credentials request was not found")]
NotFound, // no request with the given id
NotFound,
#[error("The specified request was already closed by the client")]
Abandoned, // request has already been closed by client
Abandoned,
#[error("Could not renew AWS sesssion: {0}")]
SessionRenew(#[from] GetSessionError),
}
@ -107,7 +121,8 @@ pub enum RequestError {
StreamIOError(#[from] std::io::Error),
// #[error("Received invalid UTF-8 in request")]
// InvalidUtf8,
// MalformedHttpRequest,
#[error("HTTP request malformed")]
BadRequest,
#[error("HTTP request too large")]
RequestTooLarge,
#[error("Error accessing credentials: {0}")]
@ -133,9 +148,13 @@ pub enum GetCredentialsError {
#[derive(Debug, ThisError, AsRefStr)]
pub enum GetSessionError {
#[error("Request completed successfully but no credentials were returned")]
NoCredentials, // SDK returned successfully but credentials are None
EmptyResponse, // SDK returned successfully but credentials are None
#[error("Error response from AWS SDK: {0}")]
SdkError(#[from] AwsSdkError<GetSessionTokenError>),
#[error("Could not construt session: credentials are locked")]
CredentialsLocked,
#[error("Could not construct session: no credentials are known")]
CredentialsEmpty,
}
@ -187,7 +206,6 @@ impl Serialize for SerializeWrapper<&GetSessionTokenError> {
impl_serialize_basic!(SetupError);
impl_serialize_basic!(SendResponseError);
impl_serialize_basic!(GetCredentialsError);
impl_serialize_basic!(ClientInfoError);
@ -209,6 +227,22 @@ impl Serialize for RequestError {
}
impl Serialize for SendResponseError {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(None)?;
map.serialize_entry("code", self.as_ref())?;
map.serialize_entry("msg", &format!("{self}"))?;
match self {
SendResponseError::SessionRenew(src) => map.serialize_entry("source", &src)?,
_ => serialize_upstream_err(self, &mut map)?,
}
map.end()
}
}
impl Serialize for GetSessionError {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(None)?;

View File

@ -4,7 +4,7 @@ use tauri::State;
use crate::errors::*;
use crate::config::AppConfig;
use crate::clientinfo::Client;
use crate::state::{AppState, Session, Credentials};
use crate::state::{AppState, Session, BaseCredentials};
#[derive(Clone, Debug, Serialize, Deserialize)]
@ -29,32 +29,32 @@ pub enum Approval {
#[tauri::command]
pub fn respond(response: RequestResponse, app_state: State<'_, AppState>) -> Result<(), String> {
app_state.send_response(response)
.map_err(|e| format!("Error responding to request: {e}"))
pub async fn respond(response: RequestResponse, app_state: State<'_, AppState>) -> Result<(), SendResponseError> {
app_state.send_response(response).await
}
#[tauri::command]
pub async fn unlock(passphrase: String, app_state: State<'_, AppState>) -> Result<(), UnlockError> {
app_state.decrypt(&passphrase).await
app_state.unlock(&passphrase).await
}
#[tauri::command]
pub fn get_session_status(app_state: State<'_, AppState>) -> String {
let session = app_state.session.read().unwrap();
match *session {
pub async fn get_session_status(app_state: State<'_, AppState>) -> Result<String, ()> {
let session = app_state.session.read().await;
let status = match *session {
Session::Locked(_) => "locked".into(),
Session::Unlocked(_) => "unlocked".into(),
Session::Unlocked{..} => "unlocked".into(),
Session::Empty => "empty".into()
}
};
Ok(status)
}
#[tauri::command]
pub async fn save_credentials(
credentials: Credentials,
credentials: BaseCredentials,
passphrase: String,
app_state: State<'_, AppState>
) -> Result<(), UnlockError> {
@ -63,14 +63,16 @@ pub async fn save_credentials(
#[tauri::command]
pub fn get_config(app_state: State<'_, AppState>) -> AppConfig {
let config = app_state.config.read().unwrap();
config.clone()
pub async fn get_config(app_state: State<'_, AppState>) -> Result<AppConfig, ()> {
let config = app_state.config.read().await;
Ok(config.clone())
}
#[tauri::command]
pub fn save_config(config: AppConfig, app_state: State<'_, AppState>) {
let mut prev_config = app_state.config.write().unwrap();
*prev_config = config;
pub async fn save_config(config: AppConfig, app_state: State<'_, AppState>) -> Result<(), String> {
app_state.update_config(config)
.await
.map_err(|e| format!("Error saving config: {e}"))?;
Ok(())
}

View File

@ -2,9 +2,20 @@
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
use std::error::Error;
use tauri::{AppHandle, Manager, async_runtime as rt};
use once_cell::sync::OnceCell;
use sqlx::{
SqlitePool,
sqlite::SqlitePoolOptions,
sqlite::SqliteConnectOptions,
};
use tauri::{
App,
AppHandle,
Manager,
async_runtime as rt,
};
mod config;
mod errors;
@ -14,20 +25,48 @@ mod state;
mod server;
mod tray;
use crate::errors::*;
use config::AppConfig;
use server::Server;
use errors::*;
use state::AppState;
pub static APP: OnceCell<AppHandle> = OnceCell::new();
fn main() {
let initial_state = match rt::block_on(state::AppState::load()) {
Ok(state) => state,
Err(e) => {eprintln!("{}", e); return;}
};
async fn setup(app: &mut App) -> Result<(), Box<dyn Error>> {
APP.set(app.handle()).unwrap();
let conn_opts = SqliteConnectOptions::new()
.filename(config::get_or_create_db_path()?)
.create_if_missing(true);
let pool_opts = SqlitePoolOptions::new();
let pool: SqlitePool = pool_opts.connect_with(conn_opts).await?;
sqlx::migrate!().run(&pool).await?;
let conf = AppConfig::load(&pool).await?;
let session = AppState::load_creds(&pool).await?;
let srv = Server::new(conf.listen_addr, conf.listen_port, app.handle()).await?;
config::set_auto_launch(conf.start_on_login)?;
if !conf.start_minimized {
app.get_window("main")
.ok_or(RequestError::NoMainWindow)?
.show()?;
}
let state = AppState::new(conf, session, srv, pool);
app.manage(state);
Ok(())
}
fn run() -> tauri::Result<()> {
tauri::Builder::default()
.manage(initial_state)
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
app.get_window("main")
.map(|w| w.show().error_popup("Failed to show main window"));
}))
.system_tray(tray::create())
.on_system_tray_event(tray::handle_event)
.invoke_handler(tauri::generate_handler![
@ -38,22 +77,8 @@ fn main() {
ipc::get_config,
ipc::save_config,
])
.setup(|app| {
APP.set(app.handle()).unwrap();
let state = app.state::<AppState>();
let config = state.config.read().unwrap();
let addr = std::net::SocketAddrV4::new(config.listen_addr, config.listen_port);
tauri::async_runtime::spawn(server::serve(addr, app.handle()));
if !config.start_minimized {
app.get_window("main")
.ok_or(RequestError::NoMainWindow)?
.show()?;
}
Ok(())
})
.build(tauri::generate_context!())
.expect("error while running tauri application")
.setup(|app| rt::block_on(setup(app)))
.build(tauri::generate_context!())?
.run(|app, run_event| match run_event {
tauri::RunEvent::WindowEvent { label, event, .. } => match event {
tauri::WindowEvent::CloseRequested { api, .. } => {
@ -63,39 +88,12 @@ fn main() {
_ => ()
}
_ => ()
})
});
Ok(())
}
macro_rules! get_state {
($prop:ident as $name:ident) => {
use tauri::Manager;
let app = crate::APP.get().unwrap(); // as long as the app is running, this is fine
let state = app.state::<crate::state::AppState>();
let $name = state.$prop.read().unwrap(); // only panics if another thread has already panicked
};
(config.$prop:ident as $name:ident) => {
use tauri::Manager;
let app = crate::APP.get().unwrap();
let state = app.state::<crate::state::AppState>();
let config = state.config.read().unwrap();
let $name = config.$prop;
};
(mut $prop:ident as $name:ident) => {
use tauri::Manager;
let app = crate::APP.get().unwrap();
let state = app.state::<crate::state::AppState>();
let $name = state.$prop.write().unwrap();
};
(mut config.$prop:ident as $name:ident) => {
use tauri::Manager;
let app = crate::APP.get().unwrap();
let state = app.state::<crate::state::AppState>();
let config = state.config.write().unwrap();
let $name = config.$prop;
}
fn main() {
run().error_popup("Creddy failed to start");
}
pub(crate) use get_state;

View File

@ -1,12 +1,21 @@
use core::time::Duration;
use std::io;
use std::net::{SocketAddr, SocketAddrV4};
use tokio::net::{TcpListener, TcpStream};
use std::net::{
Ipv4Addr,
SocketAddr,
SocketAddrV4,
};
use tokio::net::{
TcpListener,
TcpStream,
};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::oneshot;
use tokio::time::sleep;
use tauri::{AppHandle, Manager};
use tauri::async_runtime as rt;
use tauri::async_runtime::JoinHandle;
use crate::{clientinfo, clientinfo::Client};
use crate::errors::*;
@ -22,10 +31,10 @@ struct Handler {
}
impl Handler {
fn new(stream: TcpStream, app: AppHandle) -> Self {
async fn new(stream: TcpStream, app: AppHandle) -> Self {
let state = app.state::<AppState>();
let (chan_send, chan_recv) = oneshot::channel();
let request_id = state.register_request(chan_send);
let request_id = state.register_request(chan_send).await;
Handler {
request_id,
stream,
@ -39,13 +48,13 @@ impl Handler {
eprintln!("{e}");
}
let state = self.app.state::<AppState>();
state.unregister_request(self.request_id);
state.unregister_request(self.request_id).await;
}
async fn try_handle(&mut self) -> Result<(), RequestError> {
let _ = self.recv_request().await?;
let clients = self.get_clients()?;
if self.includes_banned(&clients) {
let clients = self.get_clients().await?;
if self.includes_banned(&clients).await {
self.stream.write(b"HTTP/1.0 403 Access Denied\r\n\r\n").await?;
return Ok(())
}
@ -59,7 +68,7 @@ impl Handler {
Approval::Denied => {
let state = self.app.state::<AppState>();
for client in req.clients {
state.add_ban(client, self.app.clone());
state.add_ban(client).await;
}
}
}
@ -68,12 +77,12 @@ impl Handler {
// and b) there are no other pending requests
let state = self.app.state::<AppState>();
let delay = {
let config = state.config.read().unwrap();
let config = state.config.read().await;
Duration::from_millis(config.rehide_ms)
};
sleep(delay).await;
if !starting_visibility && state.req_count() == 0 {
if !starting_visibility && state.req_count().await == 0 {
let window = self.app.get_window("main").ok_or(RequestError::NoMainWindow)?;
window.hide()?;
}
@ -94,21 +103,31 @@ impl Handler {
println!("{}", std::str::from_utf8(&buf).unwrap());
}
let path = buf.split(|&c| &[c] == b" ")
.skip(1)
.next()
.ok_or(RequestError::BadRequest(buf))?;
Ok(buf)
}
fn get_clients(&self) -> Result<Vec<Option<Client>>, RequestError> {
async fn get_clients(&self) -> Result<Vec<Option<Client>>, RequestError> {
let peer_addr = match self.stream.peer_addr()? {
SocketAddr::V4(addr) => addr,
_ => unreachable!(), // we only listen on IPv4
};
let clients = clientinfo::get_clients(peer_addr.port())?;
let clients = clientinfo::get_clients(peer_addr.port()).await?;
Ok(clients)
}
fn includes_banned(&self, clients: &Vec<Option<Client>>) -> bool {
async fn includes_banned(&self, clients: &Vec<Option<Client>>) -> bool {
let state = self.app.state::<AppState>();
clients.iter().any(|c| state.is_banned(c))
for client in clients {
if state.is_banned(client).await {
return true;
}
}
false
}
fn show_window(&self) -> Result<bool, RequestError> {
@ -147,7 +166,7 @@ impl Handler {
async fn send_credentials(&mut self) -> Result<(), RequestError> {
let state = self.app.state::<AppState>();
let creds = state.get_creds_serialized()?;
let creds = state.serialize_session_creds().await?;
self.stream.write(b"\r\nContent-Length: ").await?;
self.stream.write(creds.as_bytes().len().to_string().as_bytes()).await?;
@ -159,18 +178,56 @@ impl Handler {
}
pub async fn serve(addr: SocketAddrV4, app_handle: AppHandle) -> io::Result<()> {
let listener = TcpListener::bind(&addr).await?;
println!("Listening on {addr}");
#[derive(Debug)]
pub struct Server {
addr: Ipv4Addr,
port: u16,
app_handle: AppHandle,
task: JoinHandle<()>,
}
impl Server {
pub async fn new(addr: Ipv4Addr, port: u16, app_handle: AppHandle) -> io::Result<Server> {
let task = Self::start_server(addr, port, app_handle.app_handle()).await?;
Ok(Server { addr, port, app_handle, task})
}
pub async fn rebind(&mut self, addr: Ipv4Addr, port: u16) -> io::Result<()> {
if addr == self.addr && port == self.port {
return Ok(())
}
let new_task = Self::start_server(addr, port, self.app_handle.app_handle()).await?;
self.task.abort();
self.addr = addr;
self.port = port;
self.task = new_task;
Ok(())
}
// construct the listener before spawning the task so that we can return early if it fails
async fn start_server(addr: Ipv4Addr, port: u16, app_handle: AppHandle) -> io::Result<JoinHandle<()>> {
let sock_addr = SocketAddrV4::new(addr, port);
let listener = TcpListener::bind(&sock_addr).await?;
let task = rt::spawn(
Self::serve(listener, app_handle.app_handle())
);
Ok(task)
}
async fn serve(listener: TcpListener, app_handle: AppHandle) {
loop {
match listener.accept().await {
Ok((stream, _)) => {
let handler = Handler::new(stream, app_handle.app_handle());
tauri::async_runtime::spawn(handler.handle());
let handler = Handler::new(stream, app_handle.app_handle()).await;
rt::spawn(handler.handle());
},
Err(e) => {
eprintln!("Error accepting connection: {e}");
}
}
}
}
}

View File

@ -1,11 +1,22 @@
use core::time::Duration;
use std::collections::{HashMap, HashSet};
use std::sync::RwLock;
use std::time::{
Duration,
SystemTime,
UNIX_EPOCH
};
use aws_smithy_types::date_time::{
DateTime as AwsDateTime,
Format as AwsDateTimeFormat,
};
use serde::{Serialize, Deserialize};
use tokio::sync::oneshot::Sender;
use tokio::time::sleep;
use sqlx::{SqlitePool, sqlite::SqlitePoolOptions, sqlite::SqliteConnectOptions};
use tokio::{
sync::oneshot::Sender,
sync::RwLock,
time::sleep,
};
use sqlx::SqlitePool;
use sodiumoxide::crypto::{
pwhash,
pwhash::Salt,
@ -14,32 +25,47 @@ use sodiumoxide::crypto::{
};
use tauri::async_runtime as runtime;
use tauri::Manager;
use serde::Serializer;
use crate::{config, config::AppConfig};
use crate::ipc;
use crate::clientinfo::Client;
use crate::errors::*;
use crate::server::Server;
#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Credentials {
#[serde(rename_all = "PascalCase")]
LongLived {
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct BaseCredentials {
access_key_id: String,
secret_access_key: String,
},
#[serde(rename_all = "PascalCase")]
ShortLived {
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "PascalCase")]
pub struct SessionCredentials {
access_key_id: String,
secret_access_key: String,
token: String,
expiration: String,
},
#[serde(serialize_with = "serialize_expiration")]
expiration: AwsDateTime,
}
impl SessionCredentials {
fn is_expired(&self) -> bool {
let current_ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap() // doesn't panic because UNIX_EPOCH won't be later than now()
.as_secs();
let expire_ts = self.expiration.secs();
let remaining = expire_ts - (current_ts as i64);
remaining < 60
}
}
#[derive(Debug)]
#[derive(Clone, Debug)]
pub struct LockedCredentials {
access_key_id: String,
secret_key_enc: Vec<u8>,
@ -48,9 +74,12 @@ pub struct LockedCredentials {
}
#[derive(Debug)]
#[derive(Clone, Debug)]
pub enum Session {
Unlocked(Credentials),
Unlocked{
base: BaseCredentials,
session: SessionCredentials,
},
Locked(LockedCredentials),
Empty,
}
@ -63,34 +92,24 @@ pub struct AppState {
pub request_count: RwLock<u64>,
pub open_requests: RwLock<HashMap<u64, Sender<ipc::Approval>>>,
pub bans: RwLock<std::collections::HashSet<Option<Client>>>,
pool: SqlitePool,
server: RwLock<Server>,
pool: sqlx::SqlitePool,
}
impl AppState {
pub async fn load() -> Result<Self, SetupError> {
let conn_opts = SqliteConnectOptions::new()
.filename(config::get_or_create_db_path())
.create_if_missing(true);
let pool_opts = SqlitePoolOptions::new();
let pool: SqlitePool = pool_opts.connect_with(conn_opts).await?;
sqlx::migrate!().run(&pool).await?;
let creds = Self::load_creds(&pool).await?;
let conf = config::load(&pool).await?;
let state = AppState {
config: RwLock::new(conf),
session: RwLock::new(creds),
pub fn new(config: AppConfig, session: Session, server: Server, pool: SqlitePool) -> AppState {
AppState {
config: RwLock::new(config),
session: RwLock::new(session),
request_count: RwLock::new(0),
open_requests: RwLock::new(HashMap::new()),
bans: RwLock::new(HashSet::new()),
server: RwLock::new(server),
pool,
};
Ok(state)
}
}
async fn load_creds(pool: &SqlitePool) -> Result<Session, SetupError> {
pub async fn load_creds(pool: &SqlitePool) -> Result<Session, SetupError> {
let res = sqlx::query!("SELECT * FROM credentials ORDER BY created_at desc")
.fetch_optional(pool)
.await?;
@ -115,16 +134,11 @@ impl AppState {
Ok(Session::Locked(creds))
}
pub async fn save_creds(&self, creds: Credentials, passphrase: &str) -> Result<(), UnlockError> {
let (key_id, secret_key) = match creds {
Credentials::LongLived {access_key_id, secret_access_key} => {
(access_key_id, secret_access_key)
},
_ => unreachable!(),
};
pub async fn save_creds(&self, creds: BaseCredentials, passphrase: &str) -> Result<(), UnlockError> {
let BaseCredentials {access_key_id, secret_access_key} = creds;
// do this first so that if it fails we don't save bad credentials
self.new_session(&key_id, &secret_key).await?;
self.new_session(&access_key_id, &secret_access_key).await?;
let salt = pwhash::gen_salt();
let mut key_buf = [0; secretbox::KEYBYTES];
@ -133,14 +147,14 @@ impl AppState {
// not sure we need both salt AND nonce given that we generate a
// fresh salt every time we encrypt, but better safe than sorry
let nonce = secretbox::gen_nonce();
let secret_key_enc = secretbox::seal(secret_key.as_bytes(), &nonce, &key);
let secret_key_enc = secretbox::seal(secret_access_key.as_bytes(), &nonce, &key);
sqlx::query(
"INSERT INTO credentials (access_key_id, secret_key_enc, salt, nonce, created_at)
VALUES (?, ?, ?, ?, strftime('%s'))"
)
.bind(&key_id)
.bind(&access_key_id)
.bind(&secret_key_enc)
.bind(&salt.0[0..])
.bind(&nonce.0[0..])
@ -150,30 +164,50 @@ impl AppState {
Ok(())
}
pub fn register_request(&self, chan: Sender<ipc::Approval>) -> u64 {
pub async fn update_config(&self, new_config: AppConfig) -> Result<(), SetupError> {
let mut live_config = self.config.write().await;
if new_config.start_on_login != live_config.start_on_login {
config::set_auto_launch(new_config.start_on_login)?;
}
if new_config.listen_addr != live_config.listen_addr
|| new_config.listen_port != live_config.listen_port
{
let mut sv = self.server.write().await;
sv.rebind(new_config.listen_addr, new_config.listen_port).await?;
}
new_config.save(&self.pool).await?;
*live_config = new_config;
Ok(())
}
pub async fn register_request(&self, chan: Sender<ipc::Approval>) -> u64 {
let count = {
let mut c = self.request_count.write().unwrap();
let mut c = self.request_count.write().await;
*c += 1;
c
};
let mut open_requests = self.open_requests.write().unwrap();
let mut open_requests = self.open_requests.write().await;
open_requests.insert(*count, chan); // `count` is the request id
*count
}
pub fn unregister_request(&self, id: u64) {
let mut open_requests = self.open_requests.write().unwrap();
pub async fn unregister_request(&self, id: u64) {
let mut open_requests = self.open_requests.write().await;
open_requests.remove(&id);
}
pub fn req_count(&self) -> usize {
let open_requests = self.open_requests.read().unwrap();
pub async fn req_count(&self) -> usize {
let open_requests = self.open_requests.read().await;
open_requests.len()
}
pub fn send_response(&self, response: ipc::RequestResponse) -> Result<(), SendResponseError> {
let mut open_requests = self.open_requests.write().unwrap();
pub async fn send_response(&self, response: ipc::RequestResponse) -> Result<(), SendResponseError> {
self.renew_session_if_expired().await?;
let mut open_requests = self.open_requests.write().await;
let chan = open_requests
.remove(&response.id)
.ok_or(SendResponseError::NotFound)
@ -183,57 +217,75 @@ impl AppState {
.map_err(|_e| SendResponseError::Abandoned)
}
pub fn add_ban(&self, client: Option<Client>, app: tauri::AppHandle) {
let mut bans = self.bans.write().unwrap();
pub async fn add_ban(&self, client: Option<Client>) {
let mut bans = self.bans.write().await;
bans.insert(client.clone());
runtime::spawn(async move {
sleep(Duration::from_secs(5)).await;
let app = crate::APP.get().unwrap();
let state = app.state::<AppState>();
let mut bans = state.bans.write().unwrap();
let mut bans = state.bans.write().await;
bans.remove(&client);
});
}
pub fn is_banned(&self, client: &Option<Client>) -> bool {
self.bans.read().unwrap().contains(&client)
pub async fn is_banned(&self, client: &Option<Client>) -> bool {
self.bans.read().await.contains(&client)
}
pub async fn decrypt(&self, passphrase: &str) -> Result<(), UnlockError> {
let (key_id, secret) = {
// do this all in a block so that we aren't holding a lock across an await
let session = self.session.read().unwrap();
let locked = match *session {
pub async fn unlock(&self, passphrase: &str) -> Result<(), UnlockError> {
let mut session = self.session.write().await;
let LockedCredentials {
access_key_id,
secret_key_enc,
salt,
nonce
} = match *session {
Session::Empty => {return Err(UnlockError::NoCredentials);},
Session::Unlocked(_) => {return Err(UnlockError::NotLocked);},
Session::Unlocked{..} => {return Err(UnlockError::NotLocked);},
Session::Locked(ref c) => c,
};
let mut key_buf = [0; secretbox::KEYBYTES];
// pretty sure this only fails if we're out of memory
pwhash::derive_key_interactive(&mut key_buf, passphrase.as_bytes(), &locked.salt).unwrap();
let decrypted = secretbox::open(&locked.secret_key_enc, &locked.nonce, &Key(key_buf))
pwhash::derive_key_interactive(&mut key_buf, passphrase.as_bytes(), salt).unwrap();
let decrypted = secretbox::open(secret_key_enc, nonce, &Key(key_buf))
.map_err(|_e| UnlockError::BadPassphrase)?;
let secret_str = String::from_utf8(decrypted).map_err(|_e| UnlockError::InvalidUtf8)?;
(locked.access_key_id.clone(), secret_str)
};
let secret_access_key = String::from_utf8(decrypted).map_err(|_e| UnlockError::InvalidUtf8)?;
self.new_session(&key_id, &secret).await?;
let session_creds = self.new_session(access_key_id, &secret_access_key).await?;
*session = Session::Unlocked {
base: BaseCredentials {
access_key_id: access_key_id.clone(),
secret_access_key,
},
session: session_creds
};
Ok(())
}
pub fn get_creds_serialized(&self) -> Result<String, GetCredentialsError> {
let session = self.session.read().unwrap();
// pub async fn serialize_base_creds(&self) -> Result<String, GetCredentialsError> {
// let session = self.session.read().await;
// match *session {
// Session::Unlocked{ref base, ..} => Ok(serde_json::to_string(base).unwrap()),
// Session::Locked(_) => Err(GetCredentialsError::Locked),
// Session::Empty => Err(GetCredentialsError::Empty),
// }
// }
pub async fn serialize_session_creds(&self) -> Result<String, GetCredentialsError> {
let session = self.session.read().await;
match *session {
Session::Unlocked(ref creds) => Ok(serde_json::to_string(creds).unwrap()),
Session::Unlocked{ref session, ..} => Ok(serde_json::to_string(session).unwrap()),
Session::Locked(_) => Err(GetCredentialsError::Locked),
Session::Empty => Err(GetCredentialsError::Empty),
}
}
async fn new_session(&self, key_id: &str, secret_key: &str) -> Result<(), GetSessionError> {
async fn new_session(&self, key_id: &str, secret_key: &str) -> Result<SessionCredentials, GetSessionError> {
let creds = aws_sdk_sts::Credentials::new(
key_id,
secret_key,
@ -252,36 +304,58 @@ impl AppState {
.send()
.await?;
let aws_session = resp.credentials().ok_or(GetSessionError::NoCredentials)?;
let aws_session = resp.credentials().ok_or(GetSessionError::EmptyResponse)?;
let access_key_id = aws_session.access_key_id()
.ok_or(GetSessionError::NoCredentials)?
.ok_or(GetSessionError::EmptyResponse)?
.to_string();
let secret_access_key = aws_session.secret_access_key()
.ok_or(GetSessionError::NoCredentials)?
.ok_or(GetSessionError::EmptyResponse)?
.to_string();
let token = aws_session.session_token()
.ok_or(GetSessionError::NoCredentials)?
.ok_or(GetSessionError::EmptyResponse)?
.to_string();
let expiration = aws_session.expiration()
.ok_or(GetSessionError::NoCredentials)?
.fmt(aws_smithy_types::date_time::Format::DateTime)
.unwrap(); // only fails if the d/t is out of range, which it can't be for this format
.ok_or(GetSessionError::EmptyResponse)?
.clone();
let mut app_session = self.session.write().unwrap();
let session_creds = Credentials::ShortLived {
let session_creds = SessionCredentials {
access_key_id,
secret_access_key,
token,
expiration,
};
if cfg!(debug_assertions) {
#[cfg(debug_assertions)]
println!("Got new session:\n{}", serde_json::to_string(&session_creds).unwrap());
Ok(session_creds)
}
*app_session = Session::Unlocked(session_creds);
Ok(())
pub async fn renew_session_if_expired(&self) -> Result<bool, GetSessionError> {
match *self.session.write().await {
Session::Unlocked{ref base, ref mut session} => {
if !session.is_expired() {
return Ok(false);
}
let new_session = self.new_session(
&base.access_key_id,
&base.secret_access_key
).await?;
*session = new_session;
Ok(true)
},
Session::Locked(_) => Err(GetSessionError::CredentialsLocked),
Session::Empty => Err(GetSessionError::CredentialsEmpty),
}
}
}
fn serialize_expiration<S>(exp: &AwsDateTime, serializer: S) -> Result<S::Ok, S::Error>
where S: Serializer
{
// this only fails if the d/t is out of range, which it can't be for this format
let time_str = exp.fmt(AwsDateTimeFormat::DateTime).unwrap();
serializer.serialize_str(&time_str)
}

View File

@ -12,7 +12,7 @@
},
"tauri": {
"allowlist": {
"all": true
"os": {"all": true}
},
"bundle": {
"active": true,
@ -48,7 +48,10 @@
}
},
"security": {
"csp": null
"csp": {
"default-src": ["'self'"],
"style-src": ["'self'", "'unsafe-inline'"]
}
},
"updater": {
"active": false

View File

@ -1,21 +1,22 @@
<script>
import { emit, listen } from '@tauri-apps/api/event';
import { onMount } from 'svelte';
import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/tauri';
import { appState } from './lib/state.js';
import { navigate, currentView } from './lib/routing.js';
import { appState, acceptRequest } from './lib/state.js';
import { views, currentView, navigate } from './lib/routing.js';
$views = import.meta.glob('./views/*.svelte', {eager: true});
navigate('Home');
invoke('get_config').then(config => $appState.config = config);
listen('credentials-request', (tauriEvent) => {
$appState.pendingRequests.put(tauriEvent.payload);
});
// can't set this in routing.js directly for some reason
if (!$currentView) {
navigate('Home');
}
acceptRequest();
</script>

153
src/assets/vault_door.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -1,14 +1,11 @@
import { writable } from 'svelte/store';
import { writable, get } from 'svelte/store';
const VIEWS = import.meta.glob('../views/*.svelte', {eager: true});
export let views = writable();
export let currentView = writable();
export let previousView = writable();
export function navigate(viewName) {
let view = VIEWS[`../views/${viewName}.svelte`].default;
currentView.set(view);
}
export function getView(viewName) {
return VIEWS[`../views/${viewName}.svelte`].default;
let v = get(views)[`./views/${viewName}.svelte`].default;
currentView.set(v)
}

View File

@ -1,9 +1,33 @@
import { writable } from 'svelte/store';
import { writable, get } from 'svelte/store';
import queue from './queue.js';
import { navigate, currentView, previousView } from './routing.js';
export let appState = writable({
currentRequest: null,
pendingRequests: queue(),
credentialStatus: 'locked',
});
export async function acceptRequest() {
let req = await get(appState).pendingRequests.get();
appState.update($appState => {
$appState.currentRequest = req;
return $appState;
});
previousView.set(get(currentView));
navigate('Approve');
}
export function completeRequest() {
appState.update($appState => {
$appState.currentRequest = null;
return $appState;
});
currentView.set(get(previousView));
previousView.set(null);
acceptRequest();
}

View File

@ -1,3 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.btn-alert-error {
@apply bg-transparent hover:bg-[#cd5a5a] border border-error-content text-error-content
}

View File

@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
let extraClasses;
let extraClasses = "";
export {extraClasses as class};
export let slideDuration = 150;
let animationClass = "";

View File

@ -1,5 +1,5 @@
<script>
import { navigate, currentView } from '../lib/routing.js';
import { navigate } from '../lib/routing.js';
export let target;
export let hotkey = null;
@ -7,6 +7,9 @@
export let alt = false;
export let shift = false;
let classes = "";
export {classes as class};
function click() {
if (typeof target === 'string') {
navigate(target);
@ -26,10 +29,7 @@
if (alt && !event.altKey) return;
if (shift && !event.shiftKey) return;
if (event.code === hotkey) {
click();
}
else if (hotkey === 'Enter' && event.code === 'NumpadEnter') {
if (event.key === hotkey) {
click();
}
}
@ -38,6 +38,6 @@
<svelte:window on:keydown={handleHotkey} />
<a href="#" on:click="{click}">
<a href="/{target}" on:click|preventDefault="{click}" class={classes}>
<slot></slot>
</a>

View File

@ -1,21 +1,27 @@
<script>
import Link from './Link.svelte';
import Icon from './Icon.svelte';
export let position = "sticky";
</script>
<nav class="fixed top-0 grid grid-cols-2 w-full p-2">
<nav class="{position} top-0 bg-base-100 w-full flex justify-between items-center p-2">
<div>
<Link target="Home">
<button class="btn btn-squre btn-ghost align-middle">
<button class="btn btn-square btn-ghost align-middle">
<Icon name="home" class="w-8 h-8 stroke-2" />
</button>
</Link>
</div>
<div class="justify-self-end">
{#if $$slots.title}
<slot name="title"></slot>
{/if}
<div>
<Link target="Settings">
<button class="align-middle btn btn-square btn-ghost">
<button class="btn btn-square btn-ghost align-middle ">
<Icon name="cog-8-tooth" class="w-8 h-8 stroke-2" />
</button>
</Link>

View File

@ -11,26 +11,43 @@
export let decimal = false;
const dispatch = createEventDispatcher();
let error = null;
function validate(event) {
let v = event.target.value;
if (v === '') {
$: localValue = value.toString();
let lastInputTime = null;
function debounce(event) {
lastInputTime = Date.now();
localValue = localValue.replace(/[^-0-9.]/g, '');
const eventTime = lastInputTime;
const pendingValue = localValue;
window.setTimeout(
() => {
// if no other inputs have occured since then
if (eventTime === lastInputTime) {
updateValue(pendingValue);
}
},
500
)
}
let error = null;
function updateValue(newValue) {
// Don't update the value, but also don't error, if it's empty
// or if it could be the start of a negative or decimal number
if (newValue.match(/^$|^-$|^\.$/) !== null) {
error = null;
return;
}
let num = parseFloat(v);
if (Number.isNaN(num)) {
error = `"${v}" is not a number`;
}
else if (num % 1 !== 0 && !decimal) {
const num = parseFloat(newValue);
if (num % 1 !== 0 && !decimal) {
error = `${num} is not a whole number`;
}
else if (min && num < min) {
else if (min !== null && num < min) {
error = `Too low (minimum ${min})`;
}
else if (max && num > max) {
else if (max !== null && num > max) {
error = `Too large (maximum ${max})`
}
else {
@ -42,18 +59,19 @@
</script>
<Setting {title} {error}>
<Setting {title}>
<div slot="input">
{#if unit}
<span class="mr-2">{unit}:</span>
{/if}
<div class="tooltip tooltip-error" class:tooltip-open={error !== null} data-tip={error}>
<div class="tooltip tooltip-error" class:tooltip-open={error !== null} data-tip="{error}">
<input
type="text"
class="input input-sm input-bordered text-right max-w-[4rem]"
class="input input-sm input-bordered text-right"
size="{Math.max(5, localValue.length)}"
class:input-error={error}
value={value}
on:input="{validate}"
bind:value={localValue}
on:input="{debounce}"
/>
</div>
</div>

View File

@ -3,7 +3,6 @@
import ErrorAlert from '../ErrorAlert.svelte';
export let title;
export let error = null;
</script>
@ -13,6 +12,8 @@
<slot name="input"></slot>
</div>
<p class="mt-3">
{#if $$slots.description}
<p class="mt-3">
<slot name="description"></slot>
</p>
</p>
{/if}

View File

@ -10,7 +10,7 @@
</script>
<Setting title="Start minimized">
<Setting {title}>
<input
slot="input"
type="checkbox"

View File

@ -1,16 +1,35 @@
<script>
import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/tauri';
import { navigate } from '../lib/routing.js';
import { appState } from '../lib/state.js';
import { appState, completeRequest } from '../lib/state.js';
import ErrorAlert from '../ui/ErrorAlert.svelte';
import Link from '../ui/Link.svelte';
import Icon from '../ui/Icon.svelte';
// Send response to backend, display error if applicable
let error, alert;
async function respond() {
let {id, approval} = $appState.currentRequest;
try {
await invoke('respond', {response: {id, approval}});
navigate('ShowResponse');
}
catch (e) {
if (error) {
alert.shake();
}
error = e;
}
}
// Approval has one of several outcomes depending on current credential state
async function approve() {
$appState.currentRequest.approval = 'Approved';
let status = await invoke('get_session_status');
if (status === 'unlocked') {
navigate('ShowApproved');
await respond();
}
else if (status === 'locked') {
navigate('Unlock');
@ -20,34 +39,69 @@
}
}
var appName = null;
// Denial has only one
async function deny() {
$appState.currentRequest.approval = 'Denied';
await respond();
}
// Extract executable name from full path
let appName = null;
if ($appState.currentRequest.clients.length === 1) {
let path = $appState.currentRequest.clients[0].exe;
let m = path.match(/\/([^/]+?$)|\\([^\\]+?$)/);
appName = m[1] || m[2];
}
// Executable paths can be long, so ensure they only break on \ or /
function breakPath(client) {
return client.exe.replace(/(\\|\/)/g, '$1<wbr>');
}
// if the request has already been approved/denied, send response immediately
onMount(async () => {
if ($appState.currentRequest.approval) {
await respond();
}
})
</script>
<div class="flex flex-col space-y-4 p-4 m-auto max-w-max h-screen justify-center">
<!-- <div class="p-4 rounded-box border-2 border-neutral-content"> -->
<!-- Don't render at all if we're just going to immediately proceed to the next screen -->
{#if !$appState.currentRequest.approval}
<div class="flex flex-col space-y-4 p-4 m-auto max-w-xl h-screen items-center justify-center">
{#if error}
<ErrorAlert bind:this={alert}>
{error}
<svelte:fragment slot="buttons">
<button class="btn btn-sm btn-alert-error" on:click={completeRequest}>Cancel</button>
<button class="btn btn-sm btn-alert-error" on:click={respond}>Retry</button>
</svelte:fragment>
</ErrorAlert>
{/if}
<div class="space-y-1 mb-4">
<h2 class="text-xl font-bold">{appName ? `"${appName}"` : 'An appplication'} would like to access your AWS credentials.</h2>
<div class="grid grid-cols-[auto_1fr] gap-x-3">
{#each $appState.currentRequest.clients as client}
<p>Path: {client ? client.exe : 'Unknown'}</p>
<p>PID: {client ? client.pid : 'Unknown'}</p>
<div class="text-right">Path:</div>
<code class="">{@html client ? breakPath(client) : 'Unknown'}</code>
<div class="text-right">PID:</div>
<code>{client ? client.pid : 'Unknown'}</code>
{/each}
</div>
</div>
<div class="grid grid-cols-2">
<Link target="ShowDenied" hotkey="Escape">
<div class="w-full flex justify-between">
<Link target={deny} hotkey="Escape">
<button class="btn btn-error justify-self-start">
Deny
<kbd class="ml-2 normal-case px-1 py-0.5 rounded border border-neutral">Esc</kbd>
</button>
</Link>
<Link target="{approve}" hotkey="Enter" shift="{true}">
<Link target={approve} hotkey="Enter" shift="{true}">
<button class="btn btn-success justify-self-end">
Approve
<kbd class="ml-2 normal-case px-1 py-0.5 rounded border border-neutral">Shift</kbd>
@ -56,4 +110,5 @@
</button>
</Link>
</div>
</div>
</div>
{/if}

View File

@ -11,16 +11,25 @@
let errorMsg = null;
let alert;
let AccessKeyId, SecretAccessKey, passphrase
let AccessKeyId, SecretAccessKey, passphrase, confirmPassphrase
function confirm() {
if (passphrase !== confirmPassphrase) {
errorMsg = 'Passphrases do not match.'
}
}
async function save() {
console.log('Saving credentials.');
let credentials = {AccessKeyId, SecretAccessKey};
if (passphrase !== confirmPassphrase) {
alert.shake();
return;
}
let credentials = {AccessKeyId, SecretAccessKey};
try {
await invoke('save_credentials', {credentials, passphrase});
if ($appState.currentRequest) {
navigate('ShowApproved');
navigate('Approve');
}
else {
navigate('Home');
@ -54,6 +63,7 @@
<input type="text" placeholder="AWS Access Key ID" bind:value="{AccessKeyId}" class="input input-bordered" />
<input type="password" placeholder="AWS Secret Access Key" bind:value="{SecretAccessKey}" class="input input-bordered" />
<input type="password" placeholder="Passphrase" bind:value="{passphrase}" class="input input-bordered" />
<input type="password" placeholder="Re-enter passphrase" bind:value={confirmPassphrase} class="input input-bordered" on:change={confirm} />
<input type="submit" class="btn btn-primary" />
<Link target="Home" hotkey="Escape">

View File

@ -8,34 +8,41 @@
import Icon from '../ui/Icon.svelte';
import Link from '../ui/Link.svelte';
import vaultDoorSvg from '../assets/vault_door.svg?raw';
onMount(async () => {
// will block until a request comes in
let req = await $appState.pendingRequests.get();
$appState.currentRequest = req;
navigate('Approve');
});
// onMount(async () => {
// // will block until a request comes in
// let req = await $appState.pendingRequests.get();
// $appState.currentRequest = req;
// navigate('Approve');
// });
</script>
<Nav />
<Nav position="fixed">
<h2 slot="title" class="text-3xl font-bold">Creddy</h2>
</Nav>
<div class="flex flex-col h-screen justify-center items-center space-y-4">
<div class="flex flex-col h-screen items-center justify-center p-4 space-y-4">
{#await invoke('get_session_status') then status}
{#if status === 'locked'}
<img src="/static/padlock-closed.svg" alt="A locked padlock" class="w-32" />
{@html vaultDoorSvg}
<h2 class="text-2xl font-bold">Creddy is locked</h2>
<Link target="Unlock">
<button class="btn btn-primary">Unlock</button>
<Link target="Unlock" hotkey="Enter" class="w-64">
<button class="btn btn-primary w-full">Unlock</button>
</Link>
{:else if status === 'unlocked'}
<img src="/static/padlock-open.svg" alt="An unlocked padlock" class="w-24" />
{@html vaultDoorSvg}
<h2 class="text-2xl font-bold">Waiting for requests</h2>
{:else if status === 'empty'}
<Link target="EnterCredentials">
<button class="btn btn-primary">Enter Credentials</button>
{@html vaultDoorSvg}
<h2 class="text-2xl font-bold">No credentials found</h2>
<Link target="EnterCredentials" hotkey="Enter" class="w-64">
<button class="btn btn-primary w-full">Enter Credentials</button>
</Link>
{/if}
{/await}

View File

@ -1,24 +1,46 @@
<script>
import { invoke } from '@tauri-apps/api/tauri';
import { type } from '@tauri-apps/api/os';
import { appState } from '../lib/state.js';
import Nav from '../ui/Nav.svelte';
import Link from '../ui/Link.svelte';
import ErrorAlert from '../ui/ErrorAlert.svelte';
// import Setting from '../ui/settings/Setting.svelte';
import { Setting, ToggleSetting, NumericSetting } from '../ui/settings';
import { fly } from 'svelte/transition';
import { backInOut } from 'svelte/easing';
let error = null;
async function save() {
try {
await invoke('save_config', {config: $appState.config});
}
catch (e) {
error = e;
$appState.config = await invoke('get_config');
}
}
let osType = '';
type().then(t => osType = t);
</script>
<Nav />
<Nav>
<h2 slot="title" class="text-2xl font-bold">Settings</h2>
</Nav>
{#await invoke('get_config') then config}
<div class="mx-auto mt-3 max-w-md">
<h2 class="text-2xl font-bold text-center">Settings</h2>
<div class="max-w-md mx-auto mt-1.5 p-4">
<!-- <h2 class="text-2xl font-bold text-center">Settings</h2> -->
<ToggleSetting title="Start on login" bind:value={$appState.config.start_on_login} on:update={save}>
<svelte:fragment slot="description">
Start Creddy when you log in to your computer.
</svelte:fragment>
</ToggleSetting>
<ToggleSetting title="Start minimized" bind:value={$appState.config.start_minimized} on:update={save}>
<svelte:fragment slot="description">
@ -34,6 +56,18 @@
</svelte:fragment>
</NumericSetting>
<NumericSetting
title="Listen port"
bind:value={$appState.config.listen_port}
min={osType === 'Windows_NT' ? 1 : 0}
on:update={save}
>
<svelte:fragment slot="description">
Listen for credentials requests on this port.
(Should be used with <code>$AWS_CONTAINER_CREDENTIALS_FULL_URI</code>)
</svelte:fragment>
</NumericSetting>
<Setting title="Update credentials">
<Link slot="input" target="EnterCredentials">
<button class="btn btn-sm btn-primary">Update</button>
@ -44,3 +78,17 @@
</Setting>
</div>
{/await}
{#if error}
<div transition:fly={{y: 100, easing: backInOut, duration: 400}} class="toast">
<div class="alert alert-error no-animation">
<div>
<span>{error}</span>
</div>
<div>
<button class="btn btn-sm btn-alert-error" on:click={() => error = null}>Ok</button>
</div>
</div>
</div>
{/if}

View File

@ -1,75 +0,0 @@
<script>
import { onMount } from 'svelte';
import { draw, fade } from 'svelte/transition';
import { emit } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/tauri';
import { appState } from '../lib/state.js';
import { navigate } from '../lib/routing.js';
import ErrorAlert from '../ui/ErrorAlert.svelte';
import Icon from '../ui/Icon.svelte';
import Link from '../ui/Link.svelte';
let success = false;
let error = null;
async function respond() {
let response = {
id: $appState.currentRequest.id,
approval: 'Approved',
};
try {
await invoke('respond', {response});
success = true;
$appState.currentRequest = null;
window.setTimeout(() => navigate('Home'), 1000);
}
catch (e) {
error = e;
}
}
onMount(respond);
</script>
<style>
:global(body) {
overflow: hidden;
}
</style>
{#if error}
<div class="flex flex-col h-screen items-center justify-center m-auto max-w-lg">
<ErrorAlert>
{error}
<svelte:fragment slot="buttons">
<Link target="Home">
<button class="btn btn-sm bg-transparent hover:bg-[#cd5a5a] border border-error-content text-error-content">
Ok
</button>
</Link>
</svelte:fragment>
</ErrorAlert>
</div>
{:else if success}
<div class="flex flex-col h-screen items-center justify-center max-w-max m-auto">
<svg xmlns="http://www.w3.org/2000/svg" class="w-36 h-36" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor">
<path in:draw="{{duration: 500}}" stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div in:fade="{{delay: 200, duration: 300}}" class="text-2xl font-bold">Approved!</div>
</div>
{/if}
<!--
{#if error}
<div class="text-red-400">{error}</div>
{:else}
<h1 class="text-4xl text-gray-300">Approved!</h1>
{/if}
-->

View File

@ -1,56 +0,0 @@
<script>
import { onMount } from 'svelte';
import { draw, fade } from 'svelte/transition';
import { emit } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/tauri';
import { appState } from '../lib/state.js';
import { navigate } from '../lib/routing.js';
import ErrorAlert from '../ui/ErrorAlert.svelte';
import Icon from '../ui/Icon.svelte';
import Link from '../ui/Link.svelte';
let error = null;
async function respond() {
let response = {
id: $appState.currentRequest.id,
approval: 'Denied',
}
try {
await invoke('respond', {response});
$appState.currentRequest = null;
window.setTimeout(() => navigate('Home'), 1000);
}
catch (e) {
error = e;
}
}
onMount(respond);
</script>
{#if error}
<div class="flex flex-col h-screen items-center justify-center m-auto max-w-lg">
<ErrorAlert>
{error}
<svelte:fragment slot="buttons">
<Link target="Home">
<button class="btn btn-sm bg-transparent hover:bg-[#cd5a5a] border border-error-content text-error-content" on:click="{() => navigate('Home')}">
Ok
</button>
</Link>
</svelte:fragment>
</ErrorAlert>
</div>
{:else}
<div class="flex flex-col items-center justify-center h-screen max-w-max m-auto">
<svg xmlns="http://www.w3.org/2000/svg" class="w-36 h-36" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor">
<path in:draw="{{duration: 500}}" stroke-linecap="round" stroke-linejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div in:fade="{{delay: 200, duration: 300}}" class="text-2xl font-bold">Denied!</div>
</div>
{/if}

View File

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

View File

@ -1,5 +1,6 @@
<script>
import { invoke } from '@tauri-apps/api/tauri';
import { onMount } from 'svelte';
import { appState } from '../lib/state.js';
import { navigate } from '../lib/routing.js';
@ -11,12 +12,19 @@
let errorMsg = null;
let alert;
let passphrase = '';
let loadTime = 0;
async function unlock() {
// The hotkey for navigating here from homepage is Enter, which also
// happens to trigger the form submit event
if (Date.now() - loadTime < 10) {
return;
}
try {
let r = await invoke('unlock', {passphrase});
$appState.credentialStatus = 'unlocked';
if ($appState.currentRequest) {
navigate('ShowApproved');
navigate('Approve');
}
else {
navigate('Home');
@ -37,6 +45,10 @@
}
}
}
onMount(() => {
loadTime = Date.now();
})
</script>
@ -47,6 +59,7 @@
<ErrorAlert bind:this="{alert}">{errorMsg}</ErrorAlert>
{/if}
<!-- svelte-ignore a11y-autofocus -->
<input autofocus name="password" type="password" placeholder="correct horse battery staple" bind:value="{passphrase}" class="input input-bordered" />
<input type="submit" class="btn btn-primary" />

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 34 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 46 KiB