Compare commits
36 Commits
dev2
...
e746963052
Author | SHA1 | Date | |
---|---|---|---|
e746963052 | |||
b761d3b493 | |||
c5dcc2e50a | |||
70d71ce14e | |||
33a5600a30 | |||
741169d807 | |||
ebc00a5df6 | |||
c2cc007a81 | |||
4aab08e6f0 | |||
12d9d733a5 | |||
35271049dd | |||
6f9cd6b471 | |||
865b7fd5c4 | |||
f35352eedd | |||
53580d7919 | |||
049b81610d | |||
fd60899f16 | |||
e0c4c849dc | |||
cb26201506 | |||
992e3c8db2 | |||
4956b64371 | |||
df6b362a31 | |||
2943634248 | |||
06f5a1af42 | |||
61d674199f | |||
398916fe10 | |||
bf4c46238e | |||
5ffa55c03c | |||
50f0985f4f | |||
69475604c0 | |||
856b6f1e1b | |||
414379b74e | |||
80b92ebe69 | |||
983d0e8639 | |||
d77437cda8 | |||
3d5cbedae1 |
16
index.html
16
index.html
@ -1,25 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + Svelte</title>
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
min-width: 100vw;
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
<body class="bg-zinc-800">
|
||||
<div id="app"></div>
|
||||
<body id="app" class="m-0">
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
1727
package-lock.json
generated
1727
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -17,6 +17,7 @@
|
||||
"vite": "^3.0.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^1.0.2"
|
||||
"@tauri-apps/api": "^1.0.2",
|
||||
"daisyui": "^2.51.5"
|
||||
}
|
||||
}
|
||||
|
7
src-tauri/.cargo/config.toml
Normal file
7
src-tauri/.cargo/config.toml
Normal file
@ -0,0 +1,7 @@
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
linker = "clang"
|
||||
rustflags = ["-C", "link-arg=--ld-path=/usr/bin/mold"]
|
||||
|
||||
[target.x86_64-pc-windows-msvc]
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
|
||||
|
1
src-tauri/.env
Normal file
1
src-tauri/.env
Normal file
@ -0,0 +1 @@
|
||||
DATABASE_URL=sqlite://creddy.db?mode=rwc
|
718
src-tauri/Cargo.lock
generated
718
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -17,12 +17,22 @@ 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"] }
|
||||
tauri = { version = "1.0.5", features = ["api-all", "system-tray"] }
|
||||
sodiumoxide = "0.2.7"
|
||||
tokio = { version = ">=1.19", features = ["full"] }
|
||||
sqlx = { version = "0.6.2", features = ["sqlite", "runtime-tokio-rustls"] }
|
||||
netstat2 = "0.9.1"
|
||||
sysinfo = "0.26.8"
|
||||
aws-types = "0.52.0"
|
||||
aws-sdk-sts = "0.22.0"
|
||||
aws-smithy-types = "0.52.0"
|
||||
aws-config = "0.52.0"
|
||||
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
|
||||
@ -31,3 +41,6 @@ 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" ]
|
||||
|
||||
# [profile.dev.build-override]
|
||||
# opt-level = 3
|
||||
|
@ -3,12 +3,13 @@ CREATE TABLE credentials (
|
||||
access_key_id TEXT NOT NULL,
|
||||
secret_key_enc BLOB NOT NULL,
|
||||
salt BLOB NOT NULL,
|
||||
nonce BLOB NOT NULL
|
||||
nonce BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE config (
|
||||
name TEXT,
|
||||
data TEXT
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE clients (
|
||||
|
@ -1,17 +1,26 @@
|
||||
use netstat2::{AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo};
|
||||
use sysinfo::{System, SystemExt, Pid, ProcessExt};
|
||||
use sysinfo::{System, SystemExt, Pid, PidExt, ProcessExt};
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
use crate::errors::*;
|
||||
use crate::ipc::Client;
|
||||
use crate::get_state;
|
||||
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Hash)]
|
||||
pub struct Client {
|
||||
pub pid: u32,
|
||||
pub exe: String,
|
||||
}
|
||||
|
||||
|
||||
fn get_associated_pids(local_port: u16) -> Result<Vec<u32>, netstat2::error::Error> {
|
||||
let mut it = netstat2::iterate_sockets_info(
|
||||
let sockets_iter = netstat2::iterate_sockets_info(
|
||||
AddressFamilyFlags::IPV4,
|
||||
ProtocolFlags::TCP
|
||||
)?;
|
||||
|
||||
for (i, item) in it.enumerate() {
|
||||
get_state!(config as app_config);
|
||||
for item in sockets_iter {
|
||||
let sock_info = item?;
|
||||
let proto_info = match sock_info.protocol_socket_info {
|
||||
ProtocolSocketInfo::Tcp(tcp_info) => tcp_info,
|
||||
@ -19,9 +28,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 == 12345
|
||||
&& proto_info.local_addr == std::net::Ipv4Addr::LOCALHOST
|
||||
&& proto_info.remote_addr == std::net::Ipv4Addr::LOCALHOST
|
||||
&& proto_info.remote_port == app_config.listen_port
|
||||
&& proto_info.local_addr == app_config.listen_addr
|
||||
&& proto_info.remote_addr == app_config.listen_addr
|
||||
{
|
||||
return Ok(sock_info.associated_pids)
|
||||
}
|
||||
@ -30,22 +39,26 @@ fn get_associated_pids(local_port: u16) -> Result<Vec<u32>, netstat2::error::Err
|
||||
}
|
||||
|
||||
|
||||
// Theoretically, on some systems, multiple processes can share a socket. We have to
|
||||
// account for this even though 99% of the time there will be only one.
|
||||
pub fn get_clients(local_port: u16) -> Result<Vec<Client>, ClientInfoError> {
|
||||
// Theoretically, on some systems, multiple processes can share a socket
|
||||
pub 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)? {
|
||||
let pid = Pid::from(p as usize);
|
||||
let pid = Pid::from_u32(p);
|
||||
sys.refresh_process(pid);
|
||||
let proc = sys.process(pid)
|
||||
.ok_or(ClientInfoError::PidNotFound)?;
|
||||
.ok_or(ClientInfoError::ProcessNotFound)?;
|
||||
|
||||
let client = Client {
|
||||
pid: p,
|
||||
exe: proc.exe().to_string_lossy().into_owned(),
|
||||
};
|
||||
clients.push(client);
|
||||
clients.push(Some(client));
|
||||
}
|
||||
|
||||
if clients.is_empty() {
|
||||
clients.push(None);
|
||||
}
|
||||
|
||||
Ok(clients)
|
||||
}
|
||||
|
120
src-tauri/src/config.rs
Normal file
120
src-tauri/src/config.rs
Normal file
@ -0,0 +1,120 @@
|
||||
use std::net::Ipv4Addr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use auto_launch::AutoLaunchBuilder;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::errors::*;
|
||||
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
#[serde(default = "default_listen_addr")]
|
||||
pub listen_addr: Ipv4Addr,
|
||||
#[serde(default = "default_listen_port")]
|
||||
pub listen_port: u16,
|
||||
#[serde(default = "default_rehide_ms")]
|
||||
pub rehide_ms: u64,
|
||||
#[serde(default = "default_start_minimized")]
|
||||
pub start_minimized: bool,
|
||||
#[serde(default = "default_start_on_login")]
|
||||
pub start_on_login: bool,
|
||||
}
|
||||
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
AppConfig {
|
||||
listen_addr: default_listen_addr(),
|
||||
listen_port: default_listen_port(),
|
||||
rehide_ms: default_rehide_ms(),
|
||||
start_minimized: default_start_minimized(),
|
||||
start_on_login: default_start_on_login(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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?;
|
||||
|
||||
let row = match res {
|
||||
Some(row) => row,
|
||||
None => return Ok(AppConfig::default()),
|
||||
};
|
||||
|
||||
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 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()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
pub fn get_or_create_db_path() -> Result<PathBuf, DataDirError> {
|
||||
if cfg!(debug_assertions) {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
fn default_listen_port() -> u16 {
|
||||
if cfg!(debug_assertions) {
|
||||
12_345
|
||||
}
|
||||
else {
|
||||
19_923
|
||||
}
|
||||
}
|
||||
|
||||
fn default_listen_addr() -> Ipv4Addr { Ipv4Addr::LOCALHOST }
|
||||
fn default_rehide_ms() -> u64 { 1000 }
|
||||
// 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) }
|
@ -1,144 +1,258 @@
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::convert::From;
|
||||
use std::str::Utf8Error;
|
||||
use std::error::Error;
|
||||
use std::convert::AsRef;
|
||||
use strum_macros::AsRefStr;
|
||||
|
||||
use thiserror::Error as ThisError;
|
||||
|
||||
use aws_sdk_sts::{
|
||||
types::SdkError as AwsSdkError,
|
||||
error::GetSessionTokenError,
|
||||
};
|
||||
use sqlx::{
|
||||
error::Error as SqlxError,
|
||||
migrate::MigrateError,
|
||||
};
|
||||
|
||||
use serde::{Serialize, Serializer, ser::SerializeMap};
|
||||
|
||||
|
||||
// pub struct SerializeError<E> {
|
||||
// pub err: E,
|
||||
// }
|
||||
|
||||
// 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: std::error::Error> From<E> for SerializeError<E> {
|
||||
// fn from(err: E) -> Self {
|
||||
// SerializeError { err }
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
fn serialize_basic_err<E, S>(err: &E, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
E: std::error::Error + AsRef<str>,
|
||||
S: Serializer,
|
||||
{
|
||||
let mut map = serializer.serialize_map(None)?;
|
||||
map.serialize_entry("code", err.as_ref())?;
|
||||
map.serialize_entry("msg", &format!("{err}"))?;
|
||||
if let Some(src) = err.source() {
|
||||
map.serialize_entry("source", &format!("{src}"))?;
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
|
||||
|
||||
fn serialize_upstream_err<E, M>(err: &E, map: &mut M) -> Result<(), M::Error>
|
||||
where
|
||||
E: Error,
|
||||
M: serde::ser::SerializeMap,
|
||||
{
|
||||
let src = err.source().map(|s| format!("{s}"));
|
||||
map.serialize_entry("source", &src)
|
||||
}
|
||||
|
||||
|
||||
macro_rules! impl_serialize_basic {
|
||||
($err_type:ident) => {
|
||||
impl Serialize for $err_type {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serialize_basic_err(self, serializer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// error during initial setup (primarily loading state from db)
|
||||
#[derive(Debug, ThisError, AsRefStr)]
|
||||
pub enum SetupError {
|
||||
#[error("Invalid database record")]
|
||||
InvalidRecord, // e.g. wrong size blob for nonce or salt
|
||||
DbError(SqlxError),
|
||||
}
|
||||
impl From<SqlxError> for SetupError {
|
||||
fn from(e: SqlxError) -> SetupError {
|
||||
SetupError::DbError(e)
|
||||
}
|
||||
}
|
||||
impl From<MigrateError> for SetupError {
|
||||
fn from (e: MigrateError) -> SetupError {
|
||||
SetupError::DbError(SqlxError::from(e))
|
||||
}
|
||||
}
|
||||
impl Display for SetupError {
|
||||
fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> {
|
||||
match self {
|
||||
SetupError::InvalidRecord => write!(f, "Malformed database record"),
|
||||
SetupError::DbError(e) => write!(f, "Error from database: {e}"),
|
||||
}
|
||||
}
|
||||
#[error("Error from database: {0}")]
|
||||
DbError(#[from] SqlxError),
|
||||
#[error("Error running migrations: {0}")]
|
||||
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),
|
||||
}
|
||||
|
||||
|
||||
// error when attempting to tell a request handler whether to release or deny crednetials
|
||||
#[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),
|
||||
}
|
||||
|
||||
|
||||
// error when attempting to tell a request handler whether to release or deny credentials
|
||||
#[derive(Debug, ThisError, AsRefStr)]
|
||||
pub enum SendResponseError {
|
||||
#[error("The specified credentials request was not found")]
|
||||
NotFound, // no request with the given id
|
||||
#[error("The specified request was already closed by the client")]
|
||||
Abandoned, // request has already been closed by client
|
||||
}
|
||||
|
||||
impl Display for SendResponseError {
|
||||
fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> {
|
||||
use SendResponseError::*;
|
||||
match self {
|
||||
NotFound => write!(f, "The specified command was not found."),
|
||||
Abandoned => write!(f, "The specified request was closed by the client."),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// errors encountered while handling an HTTP request
|
||||
#[derive(Debug, ThisError, AsRefStr)]
|
||||
pub enum RequestError {
|
||||
StreamIOError(std::io::Error),
|
||||
InvalidUtf8,
|
||||
MalformedHttpRequest,
|
||||
#[error("Error writing to stream: {0}")]
|
||||
StreamIOError(#[from] std::io::Error),
|
||||
// #[error("Received invalid UTF-8 in request")]
|
||||
// InvalidUtf8,
|
||||
// MalformedHttpRequest,
|
||||
#[error("HTTP request too large")]
|
||||
RequestTooLarge,
|
||||
NoCredentials(GetCredentialsError),
|
||||
ClientInfo(ClientInfoError),
|
||||
}
|
||||
impl From<tokio::io::Error> for RequestError {
|
||||
fn from(e: std::io::Error) -> RequestError {
|
||||
RequestError::StreamIOError(e)
|
||||
}
|
||||
}
|
||||
impl From<Utf8Error> for RequestError {
|
||||
fn from(_e: Utf8Error) -> RequestError {
|
||||
RequestError::InvalidUtf8
|
||||
}
|
||||
}
|
||||
impl From<GetCredentialsError> for RequestError {
|
||||
fn from (e: GetCredentialsError) -> RequestError {
|
||||
RequestError::NoCredentials(e)
|
||||
}
|
||||
}
|
||||
impl From<ClientInfoError> for RequestError {
|
||||
fn from(e: ClientInfoError) -> RequestError {
|
||||
RequestError::ClientInfo(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for RequestError {
|
||||
fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> {
|
||||
use RequestError::*;
|
||||
match self {
|
||||
StreamIOError(e) => write!(f, "Stream IO error: {e}"),
|
||||
InvalidUtf8 => write!(f, "Could not decode UTF-8 from bytestream"),
|
||||
MalformedHttpRequest => write!(f, "Maformed HTTP request"),
|
||||
RequestTooLarge => write!(f, "HTTP request too large"),
|
||||
NoCredentials(GetCredentialsError::Locked) => write!(f, "Recieved go-ahead but app is locked"),
|
||||
NoCredentials(GetCredentialsError::Empty) => write!(f, "Received go-ahead but no credentials are known"),
|
||||
ClientInfo(ClientInfoError::PidNotFound) => write!(f, "Could not resolve PID of client process."),
|
||||
ClientInfo(ClientInfoError::NetstatError(e)) => write!(f, "Error getting client socket details: {e}"),
|
||||
}
|
||||
}
|
||||
#[error("Error accessing credentials: {0}")]
|
||||
NoCredentials(#[from] GetCredentialsError),
|
||||
#[error("Error getting client details: {0}")]
|
||||
ClientInfo(#[from] ClientInfoError),
|
||||
#[error("Error from Tauri: {0}")]
|
||||
Tauri(#[from] tauri::Error),
|
||||
#[error("No main application window found")]
|
||||
NoMainWindow,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, ThisError, AsRefStr)]
|
||||
pub enum GetCredentialsError {
|
||||
#[error("Credentials are currently locked")]
|
||||
Locked,
|
||||
#[error("No credentials are known")]
|
||||
Empty,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, ThisError, AsRefStr)]
|
||||
pub enum GetSessionError {
|
||||
#[error("Request completed successfully but no credentials were returned")]
|
||||
NoCredentials, // SDK returned successfully but credentials are None
|
||||
#[error("Error response from AWS SDK: {0}")]
|
||||
SdkError(#[from] AwsSdkError<GetSessionTokenError>),
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, ThisError, AsRefStr)]
|
||||
pub enum UnlockError {
|
||||
#[error("App is not locked")]
|
||||
NotLocked,
|
||||
#[error("No saved credentials were found")]
|
||||
NoCredentials,
|
||||
#[error("Invalid passphrase")]
|
||||
BadPassphrase,
|
||||
#[error("Data was found to be corrupt after decryption")]
|
||||
InvalidUtf8, // Somehow we got invalid utf-8 even though decryption succeeded
|
||||
DbError(SqlxError),
|
||||
}
|
||||
impl From<SqlxError> for UnlockError {
|
||||
fn from (e: SqlxError) -> UnlockError {
|
||||
match e {
|
||||
SqlxError::RowNotFound => UnlockError::NoCredentials,
|
||||
_ => UnlockError::DbError(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Display for UnlockError {
|
||||
fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> {
|
||||
use UnlockError::*;
|
||||
match self {
|
||||
NotLocked => write!(f, "App is not locked"),
|
||||
NoCredentials => write!(f, "No saved credentials were found"),
|
||||
BadPassphrase => write!(f, "Invalid passphrase"),
|
||||
InvalidUtf8 => write!(f, "Decrypted data was corrupted"),
|
||||
DbError(e) => write!(f, "Database error: {e}"),
|
||||
}
|
||||
}
|
||||
#[error("Database error: {0}")]
|
||||
DbError(#[from] SqlxError),
|
||||
#[error("Failed to create AWS session: {0}")]
|
||||
GetSession(#[from] GetSessionError),
|
||||
}
|
||||
|
||||
|
||||
// Errors encountered while trying to figure out who's on the other end of a request
|
||||
#[derive(Debug, ThisError, AsRefStr)]
|
||||
pub enum ClientInfoError {
|
||||
PidNotFound,
|
||||
NetstatError(netstat2::error::Error),
|
||||
#[error("Found PID for client socket, but no corresponding process")]
|
||||
ProcessNotFound,
|
||||
#[error("Couldn't get client socket details: {0}")]
|
||||
NetstatError(#[from] netstat2::error::Error),
|
||||
}
|
||||
impl From<netstat2::error::Error> for ClientInfoError {
|
||||
fn from(e: netstat2::error::Error) -> ClientInfoError {
|
||||
ClientInfoError::NetstatError(e)
|
||||
|
||||
|
||||
// =========================
|
||||
// Serialize implementations
|
||||
// =========================
|
||||
|
||||
|
||||
struct SerializeWrapper<E>(pub E);
|
||||
|
||||
impl Serialize for SerializeWrapper<&GetSessionTokenError> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let err = self.0;
|
||||
let mut map = serializer.serialize_map(None)?;
|
||||
map.serialize_entry("code", &err.code())?;
|
||||
map.serialize_entry("msg", &err.message())?;
|
||||
map.serialize_entry("source", &None::<&str>)?;
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
impl_serialize_basic!(SetupError);
|
||||
impl_serialize_basic!(SendResponseError);
|
||||
impl_serialize_basic!(GetCredentialsError);
|
||||
impl_serialize_basic!(ClientInfoError);
|
||||
|
||||
|
||||
impl Serialize for RequestError {
|
||||
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 {
|
||||
RequestError::NoCredentials(src) => map.serialize_entry("source", &src)?,
|
||||
RequestError::ClientInfo(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)?;
|
||||
map.serialize_entry("code", self.as_ref())?;
|
||||
map.serialize_entry("msg", &format!("{self}"))?;
|
||||
|
||||
match self {
|
||||
GetSessionError::SdkError(AwsSdkError::ServiceError(se_wrapper)) => {
|
||||
let err = se_wrapper.err();
|
||||
map.serialize_entry("source", &SerializeWrapper(err))?
|
||||
}
|
||||
_ => serialize_upstream_err(self, &mut map)?,
|
||||
}
|
||||
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl Serialize for UnlockError {
|
||||
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 {
|
||||
UnlockError::GetSession(src) => map.serialize_entry("source", &src)?,
|
||||
_ => serialize_upstream_err(self, &mut map)?,
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
|
@ -1,31 +1,27 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use tauri::State;
|
||||
|
||||
use crate::errors::*;
|
||||
use crate::config::AppConfig;
|
||||
use crate::clientinfo::Client;
|
||||
use crate::state::{AppState, Session, Credentials};
|
||||
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Client {
|
||||
pub pid: u32,
|
||||
pub exe: String,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Request {
|
||||
pub id: u64,
|
||||
pub clients: Vec<Client>,
|
||||
pub clients: Vec<Option<Client>>,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RequestResponse {
|
||||
pub id: u64,
|
||||
pub approval: Approval,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub enum Approval {
|
||||
Approved,
|
||||
Denied,
|
||||
@ -40,10 +36,8 @@ pub fn respond(response: RequestResponse, app_state: State<'_, AppState>) -> Res
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn unlock(passphrase: String, app_state: State<'_, AppState>) -> Result<(), String> {
|
||||
app_state.decrypt(&passphrase)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
pub async fn unlock(passphrase: String, app_state: State<'_, AppState>) -> Result<(), UnlockError> {
|
||||
app_state.decrypt(&passphrase).await
|
||||
}
|
||||
|
||||
|
||||
@ -63,8 +57,22 @@ pub async fn save_credentials(
|
||||
credentials: Credentials,
|
||||
passphrase: String,
|
||||
app_state: State<'_, AppState>
|
||||
) -> Result<(), String> {
|
||||
app_state.save_creds(credentials, &passphrase)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
) -> Result<(), UnlockError> {
|
||||
app_state.save_creds(credentials, &passphrase).await
|
||||
}
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_config(app_state: State<'_, AppState>) -> AppConfig {
|
||||
let config = app_state.config.read().unwrap();
|
||||
config.clone()
|
||||
}
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
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(())
|
||||
}
|
||||
|
@ -2,36 +2,122 @@
|
||||
all(not(debug_assertions), target_os = "windows"),
|
||||
windows_subsystem = "windows"
|
||||
)]
|
||||
use std::error::Error;
|
||||
|
||||
use std::str::FromStr;
|
||||
use sqlx::{
|
||||
SqlitePool,
|
||||
sqlite::SqlitePoolOptions,
|
||||
sqlite::SqliteConnectOptions,
|
||||
};
|
||||
use tauri::{
|
||||
App,
|
||||
AppHandle,
|
||||
Manager,
|
||||
async_runtime as rt,
|
||||
};
|
||||
use once_cell::sync::OnceCell;
|
||||
|
||||
mod config;
|
||||
mod errors;
|
||||
mod clientinfo;
|
||||
mod ipc;
|
||||
mod state;
|
||||
mod server;
|
||||
mod storage;
|
||||
mod tray;
|
||||
|
||||
use config::AppConfig;
|
||||
use server::Server;
|
||||
use errors::*;
|
||||
use state::AppState;
|
||||
|
||||
|
||||
pub static APP: OnceCell<AppHandle> = OnceCell::new();
|
||||
|
||||
|
||||
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 main() {
|
||||
let initial_state = match state::AppState::new() {
|
||||
Ok(state) => state,
|
||||
Err(e) => {eprintln!("{}", e); return;}
|
||||
};
|
||||
|
||||
tauri::Builder::default()
|
||||
.manage(initial_state)
|
||||
.system_tray(tray::create())
|
||||
.on_system_tray_event(tray::handle_event)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
ipc::unlock,
|
||||
ipc::respond,
|
||||
ipc::get_session_status,
|
||||
ipc::save_credentials,
|
||||
ipc::get_config,
|
||||
ipc::save_config,
|
||||
])
|
||||
.setup(|app| {
|
||||
let addr = std::net::SocketAddrV4::from_str("127.0.0.1:12345").unwrap();
|
||||
tauri::async_runtime::spawn(server::serve(addr, app.handle()));
|
||||
Ok(())
|
||||
.setup(|app| rt::block_on(setup(app)))
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while running tauri application")
|
||||
.run(|app, run_event| match run_event {
|
||||
tauri::RunEvent::WindowEvent { label, event, .. } => match event {
|
||||
tauri::WindowEvent::CloseRequested { api, .. } => {
|
||||
let _ = app.get_window(&label).map(|w| w.hide());
|
||||
api.prevent_close();
|
||||
}
|
||||
_ => ()
|
||||
}
|
||||
_ => ()
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub(crate) use get_state;
|
||||
|
@ -1,97 +1,224 @@
|
||||
use core::time::Duration;
|
||||
use std::io;
|
||||
use std::net::SocketAddrV4;
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use std::net::{
|
||||
Ipv4Addr,
|
||||
SocketAddr,
|
||||
SocketAddrV4,
|
||||
TcpListener as StdTcpListener,
|
||||
};
|
||||
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;
|
||||
use crate::errors::RequestError;
|
||||
use crate::{clientinfo, clientinfo::Client};
|
||||
use crate::errors::*;
|
||||
use crate::ipc::{Request, Approval};
|
||||
use crate::state::AppState;
|
||||
|
||||
|
||||
pub async fn serve(addr: SocketAddrV4, app_handle: AppHandle) -> io::Result<()> {
|
||||
let listener = TcpListener::bind(&addr).await?;
|
||||
println!("Listening on {addr}");
|
||||
loop {
|
||||
let new_handle = app_handle.app_handle();
|
||||
match listener.accept().await {
|
||||
Ok((stream, _)) => {
|
||||
tokio::spawn(async {
|
||||
if let Err(e) = handle(stream, new_handle).await {
|
||||
eprintln!("{e}");
|
||||
}
|
||||
});
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("Error accepting connection: {e}");
|
||||
struct Handler {
|
||||
request_id: u64,
|
||||
stream: TcpStream,
|
||||
receiver: Option<oneshot::Receiver<Approval>>,
|
||||
app: AppHandle,
|
||||
}
|
||||
|
||||
impl Handler {
|
||||
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);
|
||||
Handler {
|
||||
request_id,
|
||||
stream,
|
||||
receiver: Some(chan_recv),
|
||||
app
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle(mut self) {
|
||||
if let Err(e) = self.try_handle().await {
|
||||
eprintln!("{e}");
|
||||
}
|
||||
let state = self.app.state::<AppState>();
|
||||
state.unregister_request(self.request_id);
|
||||
}
|
||||
|
||||
async fn try_handle(&mut self) -> Result<(), RequestError> {
|
||||
let _ = self.recv_request().await?;
|
||||
let clients = self.get_clients()?;
|
||||
if self.includes_banned(&clients) {
|
||||
self.stream.write(b"HTTP/1.0 403 Access Denied\r\n\r\n").await?;
|
||||
return Ok(())
|
||||
}
|
||||
|
||||
let req = Request {id: self.request_id, clients};
|
||||
self.app.emit_all("credentials-request", &req)?;
|
||||
let starting_visibility = self.show_window()?;
|
||||
|
||||
match self.wait_for_response().await? {
|
||||
Approval::Approved => self.send_credentials().await?,
|
||||
Approval::Denied => {
|
||||
let state = self.app.state::<AppState>();
|
||||
for client in req.clients {
|
||||
state.add_ban(client, self.app.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// only hide the window if a) it was hidden to start with
|
||||
// and b) there are no other pending requests
|
||||
let state = self.app.state::<AppState>();
|
||||
let delay = {
|
||||
let config = state.config.read().unwrap();
|
||||
Duration::from_millis(config.rehide_ms)
|
||||
};
|
||||
sleep(delay).await;
|
||||
|
||||
if !starting_visibility && state.req_count() == 0 {
|
||||
let window = self.app.get_window("main").ok_or(RequestError::NoMainWindow)?;
|
||||
window.hide()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn recv_request(&mut self) -> Result<Vec<u8>, RequestError> {
|
||||
let mut buf = vec![0; 8192]; // it's what tokio's BufReader uses
|
||||
let mut n = 0;
|
||||
loop {
|
||||
n += self.stream.read(&mut buf[n..]).await?;
|
||||
if n >= 4 && &buf[(n - 4)..n] == b"\r\n\r\n" {break;}
|
||||
if n == buf.len() {return Err(RequestError::RequestTooLarge);}
|
||||
}
|
||||
|
||||
if cfg!(debug_assertions) {
|
||||
println!("{}", std::str::from_utf8(&buf).unwrap());
|
||||
}
|
||||
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
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())?;
|
||||
Ok(clients)
|
||||
}
|
||||
|
||||
fn includes_banned(&self, clients: &Vec<Option<Client>>) -> bool {
|
||||
let state = self.app.state::<AppState>();
|
||||
clients.iter().any(|c| state.is_banned(c))
|
||||
}
|
||||
|
||||
fn show_window(&self) -> Result<bool, RequestError> {
|
||||
let window = self.app.get_window("main").ok_or(RequestError::NoMainWindow)?;
|
||||
let starting_visibility = window.is_visible()?;
|
||||
if !starting_visibility {
|
||||
window.unminimize()?;
|
||||
window.show()?;
|
||||
}
|
||||
window.set_focus()?;
|
||||
Ok(starting_visibility)
|
||||
}
|
||||
|
||||
async fn wait_for_response(&mut self) -> Result<Approval, RequestError> {
|
||||
self.stream.write(b"HTTP/1.0 200 OK\r\n").await?;
|
||||
self.stream.write(b"Content-Type: application/json\r\n").await?;
|
||||
self.stream.write(b"X-Creddy-delaying-tactic: ").await?;
|
||||
|
||||
#[allow(unreachable_code)] // seems necessary for type inference
|
||||
let stall = async {
|
||||
let delay = std::time::Duration::from_secs(1);
|
||||
loop {
|
||||
tokio::time::sleep(delay).await;
|
||||
self.stream.write(b"x").await?;
|
||||
}
|
||||
Ok(Approval::Denied)
|
||||
};
|
||||
|
||||
// this is the only place we even read this field, so it's safe to unwrap
|
||||
let receiver = self.receiver.take().unwrap();
|
||||
tokio::select!{
|
||||
r = receiver => Ok(r.unwrap()), // only panics if the sender is dropped without sending, which shouldn't be possible
|
||||
e = stall => e,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_credentials(&mut self) -> Result<(), RequestError> {
|
||||
let state = self.app.state::<AppState>();
|
||||
let creds = state.get_creds_serialized()?;
|
||||
|
||||
self.stream.write(b"\r\nContent-Length: ").await?;
|
||||
self.stream.write(creds.as_bytes().len().to_string().as_bytes()).await?;
|
||||
self.stream.write(b"\r\n\r\n").await?;
|
||||
self.stream.write(creds.as_bytes()).await?;
|
||||
self.stream.write(b"\r\n\r\n").await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[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> {
|
||||
// construct the listener before passing it to the task so that we know if it fails
|
||||
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(Server { addr, port, app_handle, task})
|
||||
}
|
||||
|
||||
// this is blocking because it's too much of a paint to juggle mutexes otherwise
|
||||
pub fn rebind(&mut self, addr: Ipv4Addr, port: u16) -> io::Result<()> {
|
||||
if addr == self.addr && port == self.port {
|
||||
return Ok(())
|
||||
}
|
||||
|
||||
let sock_addr = SocketAddrV4::new(addr, port);
|
||||
let std_listener = StdTcpListener::bind(&sock_addr)?;
|
||||
std_listener.set_nonblocking(true)?;
|
||||
let async_listener = TcpListener::from_std(std_listener)?;
|
||||
let new_task = rt::spawn(Self::serve(async_listener, self.app_handle.app_handle()));
|
||||
self.task.abort();
|
||||
|
||||
self.addr = addr;
|
||||
self.port = port;
|
||||
self.task = new_task;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn serve(listener: TcpListener, app_handle: AppHandle) {
|
||||
loop {
|
||||
match listener.accept().await {
|
||||
Ok((stream, _)) => {
|
||||
let handler = Handler::new(stream, app_handle.app_handle());
|
||||
rt::spawn(handler.handle());
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("Error accepting connection: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// it doesn't really return Approval, we just need to placate the compiler
|
||||
async fn stall(stream: &mut TcpStream) -> Result<Approval, tokio::io::Error> {
|
||||
let delay = std::time::Duration::from_secs(1);
|
||||
loop {
|
||||
tokio::time::sleep(delay).await;
|
||||
stream.write(b"x").await?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async fn handle(mut stream: TcpStream, app_handle: AppHandle) -> Result<(), RequestError> {
|
||||
let (chan_send, chan_recv) = oneshot::channel();
|
||||
let app_state = app_handle.state::<crate::state::AppState>();
|
||||
let request_id = app_state.register_request(chan_send);
|
||||
|
||||
let peer_addr = match stream.peer_addr()? {
|
||||
std::net::SocketAddr::V4(addr) => addr,
|
||||
_ => unreachable!(), // we only listen on IPv4
|
||||
};
|
||||
let clients = clientinfo::get_clients(peer_addr.port())?;
|
||||
|
||||
// Do we want to panic if this fails? Does that mean the frontend is dead?
|
||||
let req = Request {id: request_id, clients};
|
||||
app_handle.emit_all("credentials-request", req).unwrap();
|
||||
|
||||
let mut buf = [0; 8192]; // it's what tokio's BufReader uses
|
||||
let mut n = 0;
|
||||
loop {
|
||||
n += stream.read(&mut buf[n..]).await?;
|
||||
if &buf[(n - 4)..n] == b"\r\n\r\n" {break;}
|
||||
if n == buf.len() {return Err(RequestError::RequestTooLarge);}
|
||||
}
|
||||
|
||||
println!("{}", std::str::from_utf8(&buf).unwrap());
|
||||
|
||||
stream.write(b"HTTP/1.0 200 OK\r\n").await?;
|
||||
stream.write(b"Content-Type: application/json\r\n").await?;
|
||||
stream.write(b"X-Creddy-delaying-tactic: ").await?;
|
||||
|
||||
let approval = tokio::select!{
|
||||
e = stall(&mut stream) => e?, // this will never return Ok, just Err if it can't write to the stream
|
||||
r = chan_recv => r.unwrap(), // only panics if the sender is dropped without sending, which shouldn't happen
|
||||
};
|
||||
|
||||
if matches!(approval, Approval::Denied) {
|
||||
// because we own the stream, it gets closed when we return.
|
||||
// Unfortunately we've already signaled 200 OK, there's no way around this -
|
||||
// we have to write the status code first thing, and we have to assume that the user
|
||||
// might need more time than that gives us (especially if entering the passphrase).
|
||||
// Fortunately most AWS libs automatically retry if the request dies uncompleted, allowing
|
||||
// us to respond with a proper error status.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let creds = app_state.get_creds_serialized()?;
|
||||
|
||||
stream.write(b"\r\nContent-Length: ").await?;
|
||||
stream.write(creds.as_bytes().len().to_string().as_bytes()).await?;
|
||||
stream.write(b"\r\n\r\n").await?;
|
||||
stream.write(creds.as_bytes()).await?;
|
||||
stream.write(b"\r\n\r\n").await?;
|
||||
Ok(())
|
||||
}
|
||||
|
@ -1,9 +1,11 @@
|
||||
use std::collections::HashMap;
|
||||
use core::time::Duration;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::RwLock;
|
||||
|
||||
use serde::{Serialize, Deserialize};
|
||||
use tokio::sync::oneshot::Sender;
|
||||
use sqlx::{SqlitePool, sqlite::SqlitePoolOptions, sqlite::SqliteConnectOptions};
|
||||
use tokio::time::sleep;
|
||||
use sqlx::SqlitePool;
|
||||
use sodiumoxide::crypto::{
|
||||
pwhash,
|
||||
pwhash::Salt,
|
||||
@ -11,12 +13,16 @@ use sodiumoxide::crypto::{
|
||||
secretbox::{Nonce, Key}
|
||||
};
|
||||
use tauri::async_runtime as runtime;
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::{config, config::AppConfig};
|
||||
use crate::ipc;
|
||||
use crate::clientinfo::Client;
|
||||
use crate::errors::*;
|
||||
use crate::server::Server;
|
||||
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Credentials {
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
@ -34,6 +40,7 @@ pub enum Credentials {
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LockedCredentials {
|
||||
access_key_id: String,
|
||||
secret_key_enc: Vec<u8>,
|
||||
@ -42,6 +49,7 @@ pub struct LockedCredentials {
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Session {
|
||||
Unlocked(Credentials),
|
||||
Locked(LockedCredentials),
|
||||
@ -49,44 +57,58 @@ pub enum Session {
|
||||
}
|
||||
|
||||
|
||||
// #[derive(Serialize, Deserialize)]
|
||||
// pub enum SessionStatus {
|
||||
// Unlocked,
|
||||
// Locked,
|
||||
// Empty,
|
||||
// }
|
||||
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AppState {
|
||||
pub config: RwLock<AppConfig>,
|
||||
pub session: RwLock<Session>,
|
||||
pub request_count: RwLock<u64>,
|
||||
pub open_requests: RwLock<HashMap<u64, Sender<ipc::Approval>>>,
|
||||
pool: SqlitePool,
|
||||
pub bans: RwLock<std::collections::HashSet<Option<Client>>>,
|
||||
server: RwLock<Server>,
|
||||
pool: sqlx::SqlitePool,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new() -> Result<Self, SetupError> {
|
||||
let conn_opts = SqliteConnectOptions::new()
|
||||
.filename("creddy.db")
|
||||
.create_if_missing(true);
|
||||
let pool_opts = SqlitePoolOptions::new();
|
||||
|
||||
let pool: SqlitePool = runtime::block_on(pool_opts.connect_with(conn_opts))?;
|
||||
runtime::block_on(sqlx::migrate!().run(&pool))?;
|
||||
let creds = runtime::block_on(Self::load_creds(&pool))?;
|
||||
|
||||
let state = AppState {
|
||||
session: RwLock::new(creds),
|
||||
request_count: RwLock::new(0),
|
||||
open_requests: RwLock::new(HashMap::new()),
|
||||
pool,
|
||||
};
|
||||
|
||||
Ok(state)
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_creds(pool: &SqlitePool) -> Result<Session, SetupError> {
|
||||
let res = sqlx::query!("SELECT * FROM credentials")
|
||||
// pub async fn load(app_handle: AppHandle) -> 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 = AppConfig::load(&pool).await?;
|
||||
// let server = Server::new(conf.listen_addr, conf.listen_port, app_handle)?;
|
||||
|
||||
// let state = AppState {
|
||||
// config: RwLock::new(conf),
|
||||
// session: RwLock::new(creds),
|
||||
// request_count: RwLock::new(0),
|
||||
// open_requests: RwLock::new(HashMap::new()),
|
||||
// bans: RwLock::new(HashSet::new()),
|
||||
// server: RwLock::new(server),
|
||||
// pool,
|
||||
// };
|
||||
|
||||
// Ok(state)
|
||||
// }
|
||||
|
||||
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?;
|
||||
let row = match res {
|
||||
@ -110,13 +132,17 @@ impl AppState {
|
||||
Ok(Session::Locked(creds))
|
||||
}
|
||||
|
||||
pub async fn save_creds(&self, creds: Credentials, passphrase: &str) -> Result<(), sqlx::error::Error> {
|
||||
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!(),
|
||||
};
|
||||
|
||||
// do this first so that if it fails we don't save bad credentials
|
||||
self.new_session(&key_id, &secret_key).await?;
|
||||
|
||||
let salt = pwhash::gen_salt();
|
||||
let mut key_buf = [0; secretbox::KEYBYTES];
|
||||
pwhash::derive_key_interactive(&mut key_buf, passphrase.as_bytes(), &salt).unwrap();
|
||||
@ -124,20 +150,43 @@ 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 key_enc = secretbox::seal(secret_key.as_bytes(), &nonce, &key);
|
||||
let secret_key_enc = secretbox::seal(secret_key.as_bytes(), &nonce, &key);
|
||||
|
||||
// insert into database
|
||||
|
||||
// eventually replace this with a temporary session
|
||||
let mut session = self.session.write().unwrap();
|
||||
*session = Session::Unlocked(Credentials::LongLived {
|
||||
access_key_id: key_id,
|
||||
secret_access_key: secret_key,
|
||||
});
|
||||
sqlx::query(
|
||||
"INSERT INTO credentials (access_key_id, secret_key_enc, salt, nonce, created_at)
|
||||
VALUES (?, ?, ?, ?, strftime('%s'))"
|
||||
)
|
||||
.bind(&key_id)
|
||||
.bind(&secret_key_enc)
|
||||
.bind(&salt.0[0..])
|
||||
.bind(&nonce.0[0..])
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_config(&self, new_config: AppConfig) -> Result<(), SetupError> {
|
||||
{
|
||||
let orig_config = self.config.read().unwrap();
|
||||
if new_config.start_on_login != orig_config.start_on_login {
|
||||
config::set_auto_launch(new_config.start_on_login)?;
|
||||
}
|
||||
if new_config.listen_addr != orig_config.listen_addr
|
||||
|| new_config.listen_port != orig_config.listen_port
|
||||
{
|
||||
let mut sv = self.server.write().unwrap();
|
||||
sv.rebind(new_config.listen_addr, new_config.listen_port)?;
|
||||
}
|
||||
}
|
||||
|
||||
new_config.save(&self.pool).await?;
|
||||
let mut live_config = self.config.write().unwrap();
|
||||
*live_config = new_config;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn register_request(&self, chan: Sender<ipc::Approval>) -> u64 {
|
||||
let count = {
|
||||
let mut c = self.request_count.write().unwrap();
|
||||
@ -150,6 +199,16 @@ impl AppState {
|
||||
*count
|
||||
}
|
||||
|
||||
pub fn unregister_request(&self, id: u64) {
|
||||
let mut open_requests = self.open_requests.write().unwrap();
|
||||
open_requests.remove(&id);
|
||||
}
|
||||
|
||||
pub fn req_count(&self) -> usize {
|
||||
let open_requests = self.open_requests.read().unwrap();
|
||||
open_requests.len()
|
||||
}
|
||||
|
||||
pub fn send_response(&self, response: ipc::RequestResponse) -> Result<(), SendResponseError> {
|
||||
let mut open_requests = self.open_requests.write().unwrap();
|
||||
let chan = open_requests
|
||||
@ -161,27 +220,44 @@ 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();
|
||||
bans.insert(client.clone());
|
||||
|
||||
runtime::spawn(async move {
|
||||
sleep(Duration::from_secs(5)).await;
|
||||
let state = app.state::<AppState>();
|
||||
let mut bans = state.bans.write().unwrap();
|
||||
bans.remove(&client);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn is_banned(&self, client: &Option<Client>) -> bool {
|
||||
self.bans.read().unwrap().contains(&client)
|
||||
}
|
||||
|
||||
pub async fn decrypt(&self, passphrase: &str) -> Result<(), UnlockError> {
|
||||
let session = self.session.read().unwrap();
|
||||
let locked = match *session {
|
||||
Session::Empty => {return Err(UnlockError::NoCredentials);},
|
||||
Session::Unlocked(_) => {return Err(UnlockError::NotLocked);},
|
||||
Session::Locked(ref c) => c,
|
||||
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 {
|
||||
Session::Empty => {return Err(UnlockError::NoCredentials);},
|
||||
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))
|
||||
.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 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))
|
||||
.map_err(|_e| UnlockError::BadPassphrase)?;
|
||||
self.new_session(&key_id, &secret).await?;
|
||||
|
||||
let secret_str = String::from_utf8(decrypted).map_err(|_e| UnlockError::InvalidUtf8)?;
|
||||
let mut session = self.session.write().unwrap();
|
||||
let creds = Credentials::LongLived {
|
||||
access_key_id: locked.access_key_id.clone(),
|
||||
secret_access_key: secret_str,
|
||||
};
|
||||
*session = Session::Unlocked(creds);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -193,4 +269,56 @@ impl AppState {
|
||||
Session::Empty => Err(GetCredentialsError::Empty),
|
||||
}
|
||||
}
|
||||
|
||||
async fn new_session(&self, key_id: &str, secret_key: &str) -> Result<(), GetSessionError> {
|
||||
let creds = aws_sdk_sts::Credentials::new(
|
||||
key_id,
|
||||
secret_key,
|
||||
None, // token
|
||||
None, // expiration
|
||||
"creddy", // "provider name" apparently
|
||||
);
|
||||
let config = aws_config::from_env()
|
||||
.credentials_provider(creds)
|
||||
.load()
|
||||
.await;
|
||||
|
||||
let client = aws_sdk_sts::Client::new(&config);
|
||||
let resp = client.get_session_token()
|
||||
.duration_seconds(43_200)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let aws_session = resp.credentials().ok_or(GetSessionError::NoCredentials)?;
|
||||
|
||||
let access_key_id = aws_session.access_key_id()
|
||||
.ok_or(GetSessionError::NoCredentials)?
|
||||
.to_string();
|
||||
let secret_access_key = aws_session.secret_access_key()
|
||||
.ok_or(GetSessionError::NoCredentials)?
|
||||
.to_string();
|
||||
let token = aws_session.session_token()
|
||||
.ok_or(GetSessionError::NoCredentials)?
|
||||
.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
|
||||
|
||||
let mut app_session = self.session.write().unwrap();
|
||||
let session_creds = Credentials::ShortLived {
|
||||
access_key_id,
|
||||
secret_access_key,
|
||||
token,
|
||||
expiration,
|
||||
};
|
||||
|
||||
if cfg!(debug_assertions) {
|
||||
println!("Got new session:\n{}", serde_json::to_string(&session_creds).unwrap());
|
||||
}
|
||||
|
||||
*app_session = Session::Unlocked(session_creds);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
@ -1,42 +0,0 @@
|
||||
use sodiumoxide::crypto::{pwhash, secretbox};
|
||||
|
||||
use crate::state;
|
||||
|
||||
|
||||
pub fn save(data: &str, passphrase: &str) {
|
||||
let salt = pwhash::Salt([0; 32]); // yes yes, just for now
|
||||
let mut kbuf = [0; secretbox::KEYBYTES];
|
||||
pwhash::derive_key_interactive(&mut kbuf, passphrase.as_bytes(), &salt)
|
||||
.expect("Couldn't compute password hash. Are you out of memory?");
|
||||
let key = secretbox::Key(kbuf);
|
||||
let nonce = secretbox::Nonce([0; 24]); // we don't care about e.g. replay attacks so this might be safe?
|
||||
let encrypted = secretbox::seal(data.as_bytes(), &nonce, &key);
|
||||
|
||||
//todo: store in a database, along with salt, nonce, and hash parameters
|
||||
std::fs::write("credentials.enc", &encrypted).expect("Failed to write file.");
|
||||
|
||||
//todo: key is automatically zeroed, but we should use 'zeroize' or something to zero out passphrase and data
|
||||
}
|
||||
|
||||
|
||||
// pub fn load(passphrase: &str) -> String {
|
||||
// let salt = pwhash::Salt([0; 32]);
|
||||
// let mut kbuf = [0; secretbox::KEYBYTES];
|
||||
// pwhash::derive_key_interactive(&mut kbuf, passphrase.as_bytes(), &salt)
|
||||
// .expect("Couldn't compute password hash. Are you out of memory?");
|
||||
// let key = secretbox::Key(kbuf);
|
||||
// let nonce = secretbox::Nonce([0; 24]);
|
||||
|
||||
// let encrypted = std::fs::read("credentials.enc").expect("Failed to read file.");
|
||||
// let decrypted = secretbox::open(&encrypted, &nonce, &key).expect("Failed to decrypt.");
|
||||
// String::from_utf8(decrypted).expect("Invalid utf-8")
|
||||
// }
|
||||
|
||||
pub fn load(passphrase: &str) -> state::Credentials {
|
||||
state::Credentials::ShortLived {
|
||||
access_key_id: "ASIAZ7WSVLORKQI27QGB".to_string(),
|
||||
secret_access_key: "blah".to_string(),
|
||||
token: "gah".to_string(),
|
||||
expiration: "2022-11-29T10:45:12Z".to_string(),
|
||||
}
|
||||
}
|
36
src-tauri/src/tray.rs
Normal file
36
src-tauri/src/tray.rs
Normal file
@ -0,0 +1,36 @@
|
||||
use tauri::{
|
||||
AppHandle,
|
||||
Manager,
|
||||
SystemTray,
|
||||
SystemTrayEvent,
|
||||
SystemTrayMenu,
|
||||
CustomMenuItem,
|
||||
};
|
||||
|
||||
|
||||
pub fn create() -> SystemTray {
|
||||
let show = CustomMenuItem::new("show".to_string(), "Show");
|
||||
let quit = CustomMenuItem::new("exit".to_string(), "Exit");
|
||||
|
||||
let menu = SystemTrayMenu::new()
|
||||
.add_item(show)
|
||||
.add_item(quit);
|
||||
|
||||
SystemTray::new().with_menu(menu)
|
||||
}
|
||||
|
||||
|
||||
pub fn handle_event(app: &AppHandle, event: SystemTrayEvent) {
|
||||
match event {
|
||||
SystemTrayEvent::MenuItemClick{ id, .. } => {
|
||||
match id.as_str() {
|
||||
"exit" => app.exit(0),
|
||||
"show" => {
|
||||
let _ = app.get_window("main").map(|w| w.show());
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
@ -29,7 +29,7 @@
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"identifier": "com.tauri.dev",
|
||||
"identifier": "creddy",
|
||||
"longDescription": "",
|
||||
"macOS": {
|
||||
"entitlements": null,
|
||||
@ -58,9 +58,15 @@
|
||||
"fullscreen": false,
|
||||
"height": 600,
|
||||
"resizable": true,
|
||||
"label": "main",
|
||||
"title": "Creddy",
|
||||
"width": 800
|
||||
"width": 800,
|
||||
"visible": false
|
||||
}
|
||||
]
|
||||
],
|
||||
"systemTray": {
|
||||
"iconPath": "icons/icon.png",
|
||||
"iconAsTemplate": true
|
||||
}
|
||||
}
|
||||
}
|
@ -1,37 +1,20 @@
|
||||
<script>
|
||||
import { emit, listen } from '@tauri-apps/api/event';
|
||||
import queue from './lib/queue.js';
|
||||
|
||||
const VIEWS = import.meta.glob('./views/*.svelte', {eager: true});
|
||||
|
||||
window.emit = emit;
|
||||
window.queue = queue;
|
||||
|
||||
var appState = {
|
||||
currentRequest: null,
|
||||
pendingRequests: queue(),
|
||||
credentialStatus: 'locked',
|
||||
}
|
||||
window.appState = appState;
|
||||
|
||||
|
||||
import { invoke } from '@tauri-apps/api/tauri';
|
||||
window.invoke = invoke;
|
||||
|
||||
import { appState } from './lib/state.js';
|
||||
import { views, currentView, navigate } from './lib/routing.js';
|
||||
|
||||
|
||||
var currentView = VIEWS['./views/Home.svelte'].default;
|
||||
window.currentView = currentView;
|
||||
window.VIEWS = VIEWS;
|
||||
function navigate(svelteEvent) {
|
||||
const moduleName = `./views/${svelteEvent.detail.target}.svelte`;
|
||||
currentView = VIEWS[moduleName].default;
|
||||
}
|
||||
window.navigate = navigate;
|
||||
$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);
|
||||
console.log('Received request.');
|
||||
$appState.pendingRequests.put(tauriEvent.payload);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:component this={currentView} on:navigate={navigate} bind:appState={appState} />
|
||||
|
||||
<svelte:component this="{$currentView}" />
|
||||
|
153
src/assets/vault_door.svg
Normal file
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 |
8
src/lib/errors.js
Normal file
8
src/lib/errors.js
Normal file
@ -0,0 +1,8 @@
|
||||
export function getRootCause(error) {
|
||||
if (error.source) {
|
||||
return getRootCause(error.source);
|
||||
}
|
||||
else {
|
||||
return error;
|
||||
}
|
||||
}
|
10
src/lib/routing.js
Normal file
10
src/lib/routing.js
Normal file
@ -0,0 +1,10 @@
|
||||
import { writable, get } from 'svelte/store';
|
||||
|
||||
|
||||
export let views = writable();
|
||||
export let currentView = writable();
|
||||
|
||||
export function navigate(viewName) {
|
||||
let v = get(views)[`./views/${viewName}.svelte`].default;
|
||||
currentView.set(v)
|
||||
}
|
9
src/lib/state.js
Normal file
9
src/lib/state.js
Normal file
@ -0,0 +1,9 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
import queue from './queue.js';
|
||||
|
||||
export let appState = writable({
|
||||
currentRequest: null,
|
||||
pendingRequests: queue(),
|
||||
credentialStatus: 'locked',
|
||||
});
|
@ -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
|
||||
}
|
||||
|
9
src/ui/Button.svelte
Normal file
9
src/ui/Button.svelte
Normal file
@ -0,0 +1,9 @@
|
||||
<script>
|
||||
import Icon from './Icon.svelte';
|
||||
export let icon = null;
|
||||
</script>
|
||||
|
||||
<button>
|
||||
{#if icon}<Icon name={icon} class="w-4 text-gray-200" />{/if}
|
||||
<slot></slot>
|
||||
</button>
|
67
src/ui/ErrorAlert.svelte
Normal file
67
src/ui/ErrorAlert.svelte
Normal file
@ -0,0 +1,67 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
|
||||
let extraClasses;
|
||||
export {extraClasses as class};
|
||||
export let slideDuration = 150;
|
||||
let animationClass = "";
|
||||
|
||||
export function shake() {
|
||||
animationClass = 'shake';
|
||||
window.setTimeout(() => animationClass = "", 400);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<style>
|
||||
/* animation from https://svelte.dev/repl/e606c27c864045e5a9700691a7417f99?version=3.58.0 */
|
||||
@keyframes shake {
|
||||
0% {
|
||||
transform: translateX(0px);
|
||||
}
|
||||
20% {
|
||||
transform: translateX(10px);
|
||||
}
|
||||
40% {
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
60% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
80% {
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
90% {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
95% {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0px);
|
||||
}
|
||||
}
|
||||
.shake {
|
||||
animation-name: shake;
|
||||
animation-play-state: running;
|
||||
animation-duration: 0.4s;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
<div in:slide="{{duration: slideDuration}}" class="alert alert-error shadow-lg {animationClass} {extraClasses}">
|
||||
<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="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
<span>
|
||||
<slot></slot>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if $$slots.buttons}
|
||||
<div>
|
||||
<slot name="buttons"></slot>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
11
src/ui/Icon.svelte
Normal file
11
src/ui/Icon.svelte
Normal file
@ -0,0 +1,11 @@
|
||||
<script>
|
||||
const ICONS = import.meta.glob('./icons/*.svelte', {eager: true});
|
||||
|
||||
export let name;
|
||||
let classes = "";
|
||||
export {classes as class};
|
||||
|
||||
let svg = ICONS[`./icons/${name}.svelte`].default;
|
||||
</script>
|
||||
|
||||
<svelte:component this={svg} class={classes} />
|
46
src/ui/Link.svelte
Normal file
46
src/ui/Link.svelte
Normal file
@ -0,0 +1,46 @@
|
||||
<script>
|
||||
import { navigate } from '../lib/routing.js';
|
||||
|
||||
export let target;
|
||||
export let hotkey = null;
|
||||
export let ctrl = false
|
||||
export let alt = false;
|
||||
export let shift = false;
|
||||
|
||||
let classes = "";
|
||||
export {classes as class};
|
||||
|
||||
function click() {
|
||||
if (typeof target === 'string') {
|
||||
navigate(target);
|
||||
}
|
||||
else if (typeof target === 'function') {
|
||||
target();
|
||||
}
|
||||
else {
|
||||
throw(`Link target is not a string or a function: ${target}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function handleHotkey(event) {
|
||||
if (!hotkey) return;
|
||||
if (ctrl && !event.ctrlKey) return;
|
||||
if (alt && !event.altKey) return;
|
||||
if (shift && !event.shiftKey) return;
|
||||
|
||||
if (event.code === hotkey) {
|
||||
click();
|
||||
}
|
||||
else if (hotkey === 'Enter' && event.code === 'NumpadEnter') {
|
||||
click();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<svelte:window on:keydown={handleHotkey} />
|
||||
|
||||
<a href="#" on:click="{click}" class={classes}>
|
||||
<slot></slot>
|
||||
</a>
|
29
src/ui/Nav.svelte
Normal file
29
src/ui/Nav.svelte
Normal file
@ -0,0 +1,29 @@
|
||||
<script>
|
||||
import Link from './Link.svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
|
||||
export let position = "sticky";
|
||||
</script>
|
||||
|
||||
|
||||
<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-square btn-ghost align-middle">
|
||||
<Icon name="home" class="w-8 h-8 stroke-2" />
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{#if $$slots.title}
|
||||
<slot name="title"></slot>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<Link target="Settings">
|
||||
<button class="btn btn-square btn-ghost align-middle ">
|
||||
<Icon name="cog-8-tooth" class="w-8 h-8 stroke-2" />
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
8
src/ui/icons/check-circle.svelte
Normal file
8
src/ui/icons/check-circle.svelte
Normal file
@ -0,0 +1,8 @@
|
||||
<script>
|
||||
let classes = "";
|
||||
export {classes as class};
|
||||
</script>
|
||||
|
||||
<svg class={classes} fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
9
src/ui/icons/cog-8-tooth.svelte
Normal file
9
src/ui/icons/cog-8-tooth.svelte
Normal file
@ -0,0 +1,9 @@
|
||||
<script>
|
||||
let classes = "";
|
||||
export {classes as class};
|
||||
</script>
|
||||
|
||||
<svg class="w-6 h-6 {classes}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
8
src/ui/icons/home.svelte
Normal file
8
src/ui/icons/home.svelte
Normal file
@ -0,0 +1,8 @@
|
||||
<script>
|
||||
let classes = "";
|
||||
export {classes as class};
|
||||
</script>
|
||||
|
||||
<svg class="w-6 h-6 {classes}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
|
||||
</svg>
|
8
src/ui/icons/x-circle.svelte
Normal file
8
src/ui/icons/x-circle.svelte
Normal file
@ -0,0 +1,8 @@
|
||||
<script>
|
||||
let classes = "";
|
||||
export {classes as class};
|
||||
</script>
|
||||
|
||||
<svg class={classes} fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
80
src/ui/settings/NumericSetting.svelte
Normal file
80
src/ui/settings/NumericSetting.svelte
Normal file
@ -0,0 +1,80 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
import Setting from './Setting.svelte';
|
||||
|
||||
export let title;
|
||||
export let value;
|
||||
export let unit = '';
|
||||
export let min = null;
|
||||
export let max = null;
|
||||
export let decimal = false;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
$: 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;
|
||||
}
|
||||
|
||||
const num = parseFloat(newValue);
|
||||
if (num % 1 !== 0 && !decimal) {
|
||||
error = `${num} is not a whole number`;
|
||||
}
|
||||
else if (min !== null && num < min) {
|
||||
error = `Too low (minimum ${min})`;
|
||||
}
|
||||
else if (max !== null && num > max) {
|
||||
error = `Too large (maximum ${max})`
|
||||
}
|
||||
else {
|
||||
error = null;
|
||||
value = num;
|
||||
dispatch('update', {value})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<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}">
|
||||
<input
|
||||
type="text"
|
||||
class="input input-sm input-bordered text-right"
|
||||
size="{Math.max(5, localValue.length)}"
|
||||
class:input-error={error}
|
||||
bind:value={localValue}
|
||||
on:input="{debounce}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<slot name="description" slot="description"></slot>
|
||||
</Setting>
|
19
src/ui/settings/Setting.svelte
Normal file
19
src/ui/settings/Setting.svelte
Normal file
@ -0,0 +1,19 @@
|
||||
<script>
|
||||
import { slide } from 'svelte/transition';
|
||||
import ErrorAlert from '../ErrorAlert.svelte';
|
||||
|
||||
export let title;
|
||||
</script>
|
||||
|
||||
|
||||
<div class="divider"></div>
|
||||
<div class="flex justify-between">
|
||||
<h3 class="text-lg font-bold">{title}</h3>
|
||||
<slot name="input"></slot>
|
||||
</div>
|
||||
|
||||
{#if $$slots.description}
|
||||
<p class="mt-3">
|
||||
<slot name="description"></slot>
|
||||
</p>
|
||||
{/if}
|
22
src/ui/settings/ToggleSetting.svelte
Normal file
22
src/ui/settings/ToggleSetting.svelte
Normal file
@ -0,0 +1,22 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
import Setting from './Setting.svelte';
|
||||
|
||||
export let title;
|
||||
export let value;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
</script>
|
||||
|
||||
|
||||
<Setting {title}>
|
||||
<input
|
||||
slot="input"
|
||||
type="checkbox"
|
||||
class="toggle toggle-success"
|
||||
bind:checked={value}
|
||||
on:change={e => dispatch('update', {value: e.target.checked})}
|
||||
/>
|
||||
<slot name="description" slot="description"></slot>
|
||||
</Setting>
|
3
src/ui/settings/index.js
Normal file
3
src/ui/settings/index.js
Normal file
@ -0,0 +1,3 @@
|
||||
export { default as Setting } from './Setting.svelte';
|
||||
export { default as ToggleSetting } from './ToggleSetting.svelte';
|
||||
export { default as NumericSetting } from './NumericSetting.svelte';
|
@ -1,39 +1,59 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { invoke } from '@tauri-apps/api/tauri';
|
||||
|
||||
export let appState;
|
||||
import { navigate } from '../lib/routing.js';
|
||||
import { appState } from '../lib/state.js';
|
||||
import Link from '../ui/Link.svelte';
|
||||
import Icon from '../ui/Icon.svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
async function approve() {
|
||||
let status = await invoke('get_session_status');
|
||||
if (status === 'unlocked') {
|
||||
dispatch('navigate', {target: 'ShowApproved'});
|
||||
navigate('ShowApproved');
|
||||
}
|
||||
else if (status === 'locked') {
|
||||
dispatch('navigate', {target: 'Unlock'})
|
||||
navigate('Unlock');
|
||||
}
|
||||
else {
|
||||
dispatch('navigate', {target: 'EnterCredentials'});
|
||||
navigate('EnterCredentials');
|
||||
}
|
||||
}
|
||||
|
||||
function deny() {
|
||||
dispatch('navigate', {target: 'ShowDenied'});
|
||||
var appName = null;
|
||||
if ($appState.currentRequest.clients.length === 1) {
|
||||
let path = $appState.currentRequest.clients[0].exe;
|
||||
let m = path.match(/\/([^/]+?$)|\\([^\\]+?$)/);
|
||||
appName = m[1] || m[2];
|
||||
}
|
||||
</script>
|
||||
|
||||
<h2 class="text-3xl text-gray-200">An application would like to access your AWS credentials.</h2>
|
||||
|
||||
<button on:click={approve}>
|
||||
<svg class="w-32 stroke-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<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"> -->
|
||||
<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>
|
||||
{#each $appState.currentRequest.clients as client}
|
||||
<p>Path: {client ? client.exe : 'Unknown'}</p>
|
||||
<p>PID: {client ? client.pid : 'Unknown'}</p>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<button on:click={deny}>
|
||||
<svg class="w-32 stroke-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="grid grid-cols-2">
|
||||
<Link target="ShowDenied" 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}">
|
||||
<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>
|
||||
<span class="mx-0.5">+</span>
|
||||
<kbd class="normal-case px-1 py-0.5 rounded border border-neutral">Enter</kbd>
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
@ -1,41 +1,72 @@
|
||||
<script>
|
||||
import { onMount, createEventDispatcher } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { invoke } from '@tauri-apps/api/tauri';
|
||||
import { getRootCause } from '../lib/errors.js';
|
||||
|
||||
export let appState;
|
||||
import { appState } from '../lib/state.js';
|
||||
import { navigate } from '../lib/routing.js';
|
||||
import Link from '../ui/Link.svelte';
|
||||
import ErrorAlert from '../ui/ErrorAlert.svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let AccessKeyId, SecretAccessKey, passphrase
|
||||
|
||||
let errorMsg = null;
|
||||
let alert;
|
||||
let AccessKeyId, SecretAccessKey, passphrase, confirmPassphrase
|
||||
|
||||
function confirm() {
|
||||
if (passphrase !== confirmPassphrase) {
|
||||
errorMsg = 'Passphrases do not match.'
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (passphrase !== confirmPassphrase) {
|
||||
alert.shake();
|
||||
return;
|
||||
}
|
||||
|
||||
let credentials = {AccessKeyId, SecretAccessKey};
|
||||
try {
|
||||
console.log('Saving credentials.');
|
||||
let credentials = {AccessKeyId, SecretAccessKey};
|
||||
console.log(credentials);
|
||||
await invoke('save_credentials', {credentials, passphrase});
|
||||
if (appState.currentRequest) {
|
||||
dispatch('navigate', {target: 'ShowApproved'})
|
||||
if ($appState.currentRequest) {
|
||||
navigate('ShowApproved');
|
||||
}
|
||||
else {
|
||||
dispatch('navigate', {target: 'Home'})
|
||||
navigate('Home');
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.log("Error saving credentials:", e);
|
||||
if (e.code === "GetSession") {
|
||||
let root = getRootCause(e);
|
||||
errorMsg = `Error response from AWS (${root.code}): ${root.msg}`;
|
||||
}
|
||||
else {
|
||||
errorMsg = e.msg;
|
||||
}
|
||||
|
||||
if (alert) {
|
||||
alert.shake();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<form action="#" on:submit|preventDefault="{save}">
|
||||
<div class="text-gray-200">AWS Access Key ID</div>
|
||||
<input class="text-gray-200 bg-zinc-800" type="text" bind:value="{AccessKeyId}" />
|
||||
|
||||
<div class="text-gray-200">AWS Secret Access Key</div>
|
||||
<input class="text-gray-200 bg-zinc-800" type="text" bind:value="{SecretAccessKey}" />
|
||||
<form action="#" on:submit|preventDefault="{save}" class="form-control space-y-4 max-w-sm m-auto p-4 h-screen justify-center">
|
||||
<h2 class="text-2xl font-bold text-center">Enter your credentials</h2>
|
||||
|
||||
<div class="text-gray-200">Passphrase</div>
|
||||
<input class="text-gray-200 bg-zinc-800" type="text" bind:value="{passphrase}" />
|
||||
{#if errorMsg}
|
||||
<ErrorAlert bind:this="{alert}">{errorMsg}</ErrorAlert>
|
||||
{/if}
|
||||
|
||||
<input class="text-gray-200" type="submit" />
|
||||
<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">
|
||||
<button class="btn btn-sm btn-outline w-full">Cancel</button>
|
||||
</Link>
|
||||
</form>
|
||||
|
@ -1,18 +1,51 @@
|
||||
<script>
|
||||
import { onMount, createEventDispatcher } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { invoke } from '@tauri-apps/api/tauri';
|
||||
|
||||
export let appState;
|
||||
import { appState } from '../lib/state.js';
|
||||
import { navigate } from '../lib/routing.js';
|
||||
import Nav from '../ui/Nav.svelte';
|
||||
import Icon from '../ui/Icon.svelte';
|
||||
import Link from '../ui/Link.svelte';
|
||||
|
||||
import vaultDoorSvg from '../assets/vault_door.svg?raw';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
onMount(async () => {
|
||||
// will block until a request comes in
|
||||
let req = await appState.pendingRequests.get();
|
||||
appState.currentRequest = req;
|
||||
console.log('Got credentials request from queue:');
|
||||
console.log(req);
|
||||
dispatch('navigate', {target: 'Approve'});
|
||||
let req = await $appState.pendingRequests.get();
|
||||
$appState.currentRequest = req;
|
||||
navigate('Approve');
|
||||
});
|
||||
</script>
|
||||
|
||||
<h1 class="text-4xl text-gray-300">Creddy</h1>
|
||||
|
||||
<Nav position="fixed">
|
||||
<h2 slot="title" class="text-3xl font-bold">Creddy</h2>
|
||||
</Nav>
|
||||
|
||||
<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={closedPadlockUrl} alt="A locked padlock" class="w-32" /> -->
|
||||
|
||||
{@html vaultDoorSvg}
|
||||
|
||||
<h2 class="text-2xl font-bold">Creddy is locked</h2>
|
||||
<Link target="Unlock" class="w-64">
|
||||
<button class="btn btn-primary w-full">Unlock</button>
|
||||
</Link>
|
||||
|
||||
{:else if status === 'unlocked'}
|
||||
{@html vaultDoorSvg}
|
||||
<h2 class="text-2xl font-bold">Waiting for requests</h2>
|
||||
|
||||
{:else if status === 'empty'}
|
||||
{@html vaultDoorSvg}
|
||||
<h2 class="text-2xl font-bold">No credentials found</h2>
|
||||
<Link target="EnterCredentials" class="w-64">
|
||||
<button class="btn btn-primary w-full">Enter Credentials</button>
|
||||
</Link>
|
||||
{/if}
|
||||
{/await}
|
||||
</div>
|
85
src/views/Settings.svelte
Normal file
85
src/views/Settings.svelte
Normal file
@ -0,0 +1,85 @@
|
||||
<script>
|
||||
import { invoke } from '@tauri-apps/api/tauri';
|
||||
|
||||
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, 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');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<Nav>
|
||||
<h2 slot="title" class="text-2xl font-bold">Settings</h2>
|
||||
</Nav>
|
||||
|
||||
{#await invoke('get_config') then config}
|
||||
<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">
|
||||
Minimize to the system tray at startup.
|
||||
</svelte:fragment>
|
||||
</ToggleSetting>
|
||||
|
||||
<NumericSetting title="Re-hide delay" bind:value={$appState.config.rehide_ms} min={0} unit="Milliseconds" on:update={save}>
|
||||
<svelte:fragment slot="description">
|
||||
How long to wait after a request is approved/denied before minimizing
|
||||
the window to tray. Only applicable if the window was minimized
|
||||
to tray before the request was received.
|
||||
</svelte:fragment>
|
||||
</NumericSetting>
|
||||
|
||||
<NumericSetting title="Listen port" bind:value={$appState.config.listen_port} min=1 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>
|
||||
</Link>
|
||||
<svelte:fragment slot="description">
|
||||
Update or re-enter your encrypted credentials.
|
||||
</svelte:fragment>
|
||||
</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}
|
@ -1,21 +1,76 @@
|
||||
<script>
|
||||
import { onMount, createEventDispatcher } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { draw, fade } from 'svelte/transition';
|
||||
import { emit } from '@tauri-apps/api/event';
|
||||
import { invoke } from '@tauri-apps/api/tauri';
|
||||
|
||||
export let appState;
|
||||
|
||||
onMount(async () => {
|
||||
let response = {
|
||||
id: appState.currentRequest.id,
|
||||
approval: 'Approved',
|
||||
}
|
||||
await invoke('respond', {response});
|
||||
appState.currentRequest = null;
|
||||
});
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
window.setTimeout(() => dispatch('navigate', {target: 'Home'}), 3000);
|
||||
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;
|
||||
|
||||
let drawDuration = $appState.config.rehide_ms >= 750 ? 500 : 0;
|
||||
let fadeDuration = drawDuration * 0.6;
|
||||
let fadeDelay = drawDuration * 0.4;
|
||||
|
||||
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'),
|
||||
// Extra 50ms so the window can finish disappearing before the screen changes
|
||||
Math.min(5000, $appState.config.rehide_ms + 50),
|
||||
);
|
||||
}
|
||||
catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
}
|
||||
onMount(respond);
|
||||
</script>
|
||||
|
||||
<h1 class="text-4xl text-gray-300">Approved!</h1>
|
||||
|
||||
{#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 btn-alert-error" on:click={() => navigate('Home')}>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: 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>
|
||||
|
||||
|
||||
<div in:fade="{{duration: fadeDuration, delay: fadeDelay}}" 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}
|
||||
-->
|
@ -1,21 +1,54 @@
|
||||
<script>
|
||||
import { onMount, createEventDispatcher } from 'svelte';
|
||||
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';
|
||||
|
||||
export let appState;
|
||||
let error = null;
|
||||
|
||||
onMount(async () => {
|
||||
async function respond() {
|
||||
let response = {
|
||||
id: appState.currentRequest.id,
|
||||
id: $appState.currentRequest.id,
|
||||
approval: 'Denied',
|
||||
}
|
||||
await invoke('respond', {response});
|
||||
appState.currentRequest = null;
|
||||
});
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
window.setTimeout(() => dispatch('navigate', {target: 'Home'}), 3000);
|
||||
try {
|
||||
await invoke('respond', {response});
|
||||
$appState.currentRequest = null;
|
||||
window.setTimeout(() => navigate('Home'), 1000);
|
||||
}
|
||||
catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
onMount(respond);
|
||||
</script>
|
||||
|
||||
<h1 class="text-4xl text-gray-300">Denied!</h1>
|
||||
{#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 btn-alert-error" 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}
|
@ -1,25 +1,56 @@
|
||||
<script>
|
||||
import { invoke } from '@tauri-apps/api/tauri';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let appState;
|
||||
import { appState } from '../lib/state.js';
|
||||
import { navigate } from '../lib/routing.js';
|
||||
import { getRootCause } from '../lib/errors.js';
|
||||
import ErrorAlert from '../ui/ErrorAlert.svelte';
|
||||
import Link from '../ui/Link.svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let errorMsg = null;
|
||||
let alert;
|
||||
let passphrase = '';
|
||||
async function unlock() {
|
||||
console.log('invoking unlock command.')
|
||||
|
||||
try {
|
||||
await invoke('unlock', {passphrase});
|
||||
let r = await invoke('unlock', {passphrase});
|
||||
$appState.credentialStatus = 'unlocked';
|
||||
if ($appState.currentRequest) {
|
||||
navigate('ShowApproved');
|
||||
}
|
||||
else {
|
||||
navigate('Home');
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.log('Unlock error:', e);
|
||||
window.error = e;
|
||||
if (e.code === 'GetSession') {
|
||||
let root = getRootCause(e);
|
||||
errorMsg = `Error response from AWS (${root.code}): ${root.msg}`;
|
||||
}
|
||||
else {
|
||||
errorMsg = e.msg;
|
||||
}
|
||||
|
||||
if (alert) {
|
||||
alert.shake();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form action="#" on:submit|preventDefault="{unlock}">
|
||||
<div class="text-gray-200">Enter your passphrase:</div>
|
||||
<input class="text-gray-200 bg-zinc-800" type="password" placeholder="correct horse battery staple" bind:value="{passphrase}" />
|
||||
|
||||
<form action="#" on:submit|preventDefault="{unlock}" class="form-control space-y-4 max-w-sm m-auto p-4 h-screen justify-center">
|
||||
<h2 class="font-bold text-2xl text-center">Enter your passphrase</h2>
|
||||
|
||||
{#if errorMsg}
|
||||
<ErrorAlert bind:this="{alert}">{errorMsg}</ErrorAlert>
|
||||
{/if}
|
||||
|
||||
<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" />
|
||||
<Link target="Home" hotkey="Escape">
|
||||
<button class="btn btn-outline btn-sm w-full">Cancel</button>
|
||||
</Link>
|
||||
</form>
|
||||
|
@ -7,5 +7,7 @@ module.exports = {
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
plugins: [
|
||||
require('daisyui'),
|
||||
],
|
||||
}
|
||||
|
Reference in New Issue
Block a user