mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2025-02-05 14:49:06 +00:00
Refactored runtime (#192)
This commit is contained in:
parent
58d7478f3a
commit
d0a366258a
84
runtime/src/api_token.rs
Normal file
84
runtime/src/api_token.rs
Normal file
@ -0,0 +1,84 @@
|
||||
use log::info;
|
||||
use once_cell::sync::Lazy;
|
||||
use rand::{RngCore, SeedableRng};
|
||||
use rocket::http::Status;
|
||||
use rocket::Request;
|
||||
use rocket::request::FromRequest;
|
||||
|
||||
/// The API token used to authenticate requests.
|
||||
pub static API_TOKEN: Lazy<APIToken> = Lazy::new(|| {
|
||||
let mut token = [0u8; 32];
|
||||
let mut rng = rand_chacha::ChaChaRng::from_entropy();
|
||||
rng.fill_bytes(&mut token);
|
||||
|
||||
let token = APIToken::from_bytes(token.to_vec());
|
||||
info!("API token was generated successfully.");
|
||||
|
||||
token
|
||||
});
|
||||
|
||||
/// The API token data structure used to authenticate requests.
|
||||
pub struct APIToken {
|
||||
hex_text: String,
|
||||
}
|
||||
|
||||
impl APIToken {
|
||||
/// Creates a new API token from a byte vector.
|
||||
fn from_bytes(bytes: Vec<u8>) -> Self {
|
||||
APIToken {
|
||||
hex_text: bytes.iter().fold(String::new(), |mut result, byte| {
|
||||
result.push_str(&format!("{:02x}", byte));
|
||||
result
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new API token from a hexadecimal text.
|
||||
fn from_hex_text(hex_text: &str) -> Self {
|
||||
APIToken {
|
||||
hex_text: hex_text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn to_hex_text(&self) -> &str {
|
||||
self.hex_text.as_str()
|
||||
}
|
||||
|
||||
/// Validates the received token against the valid token.
|
||||
fn validate(&self, received_token: &Self) -> bool {
|
||||
received_token.to_hex_text() == self.to_hex_text()
|
||||
}
|
||||
}
|
||||
|
||||
/// The request outcome type used to handle API token requests.
|
||||
type RequestOutcome<R, T> = rocket::request::Outcome<R, T>;
|
||||
|
||||
/// The request outcome implementation for the API token.
|
||||
#[rocket::async_trait]
|
||||
impl<'r> FromRequest<'r> for APIToken {
|
||||
type Error = APITokenError;
|
||||
|
||||
/// Handles the API token requests.
|
||||
async fn from_request(request: &'r Request<'_>) -> RequestOutcome<Self, Self::Error> {
|
||||
let token = request.headers().get_one("token");
|
||||
match token {
|
||||
Some(token) => {
|
||||
let received_token = APIToken::from_hex_text(token);
|
||||
if API_TOKEN.validate(&received_token) {
|
||||
RequestOutcome::Success(received_token)
|
||||
} else {
|
||||
RequestOutcome::Error((Status::Unauthorized, APITokenError::Invalid))
|
||||
}
|
||||
}
|
||||
|
||||
None => RequestOutcome::Error((Status::Unauthorized, APITokenError::Missing)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The API token error types.
|
||||
#[derive(Debug)]
|
||||
pub enum APITokenError {
|
||||
Missing,
|
||||
Invalid,
|
||||
}
|
222
runtime/src/app_window.rs
Normal file
222
runtime/src/app_window.rs
Normal file
@ -0,0 +1,222 @@
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
use log::{error, info, warn};
|
||||
use once_cell::sync::Lazy;
|
||||
use rocket::get;
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::serde::Serialize;
|
||||
use tauri::updater::UpdateResponse;
|
||||
use tauri::{Manager, Window};
|
||||
use tokio::time;
|
||||
use crate::api_token::APIToken;
|
||||
use crate::dotnet::stop_dotnet_server;
|
||||
use crate::environment::{is_prod, CONFIG_DIRECTORY, DATA_DIRECTORY};
|
||||
use crate::log::switch_to_file_logging;
|
||||
|
||||
/// The Tauri main window.
|
||||
static MAIN_WINDOW: Lazy<Mutex<Option<Window>>> = Lazy::new(|| Mutex::new(None));
|
||||
|
||||
/// The update response coming from the Tauri updater.
|
||||
static CHECK_UPDATE_RESPONSE: Lazy<Mutex<Option<UpdateResponse<tauri::Wry>>>> = Lazy::new(|| Mutex::new(None));
|
||||
|
||||
/// Starts the Tauri app.
|
||||
pub fn start_tauri() {
|
||||
info!("Starting Tauri app...");
|
||||
let app = tauri::Builder::default()
|
||||
.setup(move |app| {
|
||||
let window = app.get_window("main").expect("Failed to get main window.");
|
||||
*MAIN_WINDOW.lock().unwrap() = Some(window);
|
||||
|
||||
info!(Source = "Bootloader Tauri"; "Setup is running.");
|
||||
let logger_path = app.path_resolver().app_local_data_dir().unwrap();
|
||||
let logger_path = logger_path.join("data");
|
||||
|
||||
DATA_DIRECTORY.set(logger_path.to_str().unwrap().to_string()).map_err(|_| error!("Was not abe to set the data directory.")).unwrap();
|
||||
CONFIG_DIRECTORY.set(app.path_resolver().app_config_dir().unwrap().to_str().unwrap().to_string()).map_err(|_| error!("Was not able to set the config directory.")).unwrap();
|
||||
|
||||
info!(Source = "Bootloader Tauri"; "Reconfigure the file logger to use the app data directory {logger_path:?}");
|
||||
switch_to_file_logging(logger_path).map_err(|e| error!("Failed to switch logging to file: {e}")).unwrap();
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.build(tauri::generate_context!())
|
||||
.expect("Error while running Tauri application");
|
||||
|
||||
app.run(|app_handle, event| match event {
|
||||
|
||||
tauri::RunEvent::WindowEvent { event, label, .. } => {
|
||||
match event {
|
||||
tauri::WindowEvent::CloseRequested { .. } => {
|
||||
warn!(Source = "Tauri"; "Window '{label}': close was requested.");
|
||||
}
|
||||
|
||||
tauri::WindowEvent::Destroyed => {
|
||||
warn!(Source = "Tauri"; "Window '{label}': was destroyed.");
|
||||
}
|
||||
|
||||
tauri::WindowEvent::FileDrop(files) => {
|
||||
info!(Source = "Tauri"; "Window '{label}': files were dropped: {files:?}");
|
||||
}
|
||||
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
tauri::RunEvent::Updater(updater_event) => {
|
||||
match updater_event {
|
||||
|
||||
tauri::UpdaterEvent::UpdateAvailable { body, date, version } => {
|
||||
let body_len = body.len();
|
||||
info!(Source = "Tauri"; "Updater: update available: body size={body_len} time={date:?} version={version}");
|
||||
}
|
||||
|
||||
tauri::UpdaterEvent::Pending => {
|
||||
info!(Source = "Tauri"; "Updater: update is pending!");
|
||||
}
|
||||
|
||||
tauri::UpdaterEvent::DownloadProgress { chunk_length, content_length } => {
|
||||
info!(Source = "Tauri"; "Updater: downloaded {} of {:?}", chunk_length, content_length);
|
||||
}
|
||||
|
||||
tauri::UpdaterEvent::Downloaded => {
|
||||
info!(Source = "Tauri"; "Updater: update has been downloaded!");
|
||||
warn!(Source = "Tauri"; "Try to stop the .NET server now...");
|
||||
stop_dotnet_server();
|
||||
}
|
||||
|
||||
tauri::UpdaterEvent::Updated => {
|
||||
info!(Source = "Tauri"; "Updater: app has been updated");
|
||||
warn!(Source = "Tauri"; "Try to restart the app now...");
|
||||
app_handle.restart();
|
||||
}
|
||||
|
||||
tauri::UpdaterEvent::AlreadyUpToDate => {
|
||||
info!(Source = "Tauri"; "Updater: app is already up to date");
|
||||
}
|
||||
|
||||
tauri::UpdaterEvent::Error(error) => {
|
||||
warn!(Source = "Tauri"; "Updater: failed to update: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tauri::RunEvent::ExitRequested { .. } => {
|
||||
warn!(Source = "Tauri"; "Run event: exit was requested.");
|
||||
}
|
||||
|
||||
tauri::RunEvent::Ready => {
|
||||
info!(Source = "Tauri"; "Run event: Tauri app is ready.");
|
||||
}
|
||||
|
||||
_ => {}
|
||||
});
|
||||
|
||||
warn!(Source = "Tauri"; "Tauri app was stopped.");
|
||||
if is_prod() {
|
||||
warn!("Try to stop the .NET server as well...");
|
||||
stop_dotnet_server();
|
||||
}
|
||||
}
|
||||
|
||||
/// Changes the location of the main window to the given URL.
|
||||
pub async fn change_location_to(url: &str) {
|
||||
// Try to get the main window. If it is not available yet, wait for it:
|
||||
let mut main_window_ready = false;
|
||||
let mut main_window_status_reported = false;
|
||||
let main_window_spawn_clone = &MAIN_WINDOW;
|
||||
while !main_window_ready
|
||||
{
|
||||
main_window_ready = {
|
||||
let main_window = main_window_spawn_clone.lock().unwrap();
|
||||
main_window.is_some()
|
||||
};
|
||||
|
||||
if !main_window_ready {
|
||||
if !main_window_status_reported {
|
||||
info!("Waiting for main window to be ready, because .NET was faster than Tauri.");
|
||||
main_window_status_reported = true;
|
||||
}
|
||||
|
||||
time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
|
||||
let js_location_change = format!("window.location = '{url}';");
|
||||
let main_window = main_window_spawn_clone.lock().unwrap();
|
||||
let location_change_result = main_window.as_ref().unwrap().eval(js_location_change.as_str());
|
||||
match location_change_result {
|
||||
Ok(_) => info!("The app location was changed to {url}."),
|
||||
Err(e) => error!("Failed to change the app location to {url}: {e}."),
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks for updates.
|
||||
#[get("/updates/check")]
|
||||
pub async fn check_for_update(_token: APIToken) -> Json<CheckUpdateResponse> {
|
||||
let app_handle = MAIN_WINDOW.lock().unwrap().as_ref().unwrap().app_handle();
|
||||
let response = app_handle.updater().check().await;
|
||||
match response {
|
||||
Ok(update_response) => match update_response.is_update_available() {
|
||||
true => {
|
||||
*CHECK_UPDATE_RESPONSE.lock().unwrap() = Some(update_response.clone());
|
||||
let new_version = update_response.latest_version();
|
||||
info!(Source = "Updater"; "An update to version '{new_version}' is available.");
|
||||
let changelog = update_response.body();
|
||||
Json(CheckUpdateResponse {
|
||||
update_is_available: true,
|
||||
error: false,
|
||||
new_version: new_version.to_string(),
|
||||
changelog: match changelog {
|
||||
Some(c) => c.to_string(),
|
||||
None => String::from(""),
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
false => {
|
||||
info!(Source = "Updater"; "No updates are available.");
|
||||
Json(CheckUpdateResponse {
|
||||
update_is_available: false,
|
||||
error: false,
|
||||
new_version: String::from(""),
|
||||
changelog: String::from(""),
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
Err(e) => {
|
||||
warn!(Source = "Updater"; "Failed to check for updates: {e}.");
|
||||
Json(CheckUpdateResponse {
|
||||
update_is_available: false,
|
||||
error: true,
|
||||
new_version: String::from(""),
|
||||
changelog: String::from(""),
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The response to the check for update request.
|
||||
#[derive(Serialize)]
|
||||
pub struct CheckUpdateResponse {
|
||||
update_is_available: bool,
|
||||
error: bool,
|
||||
new_version: String,
|
||||
changelog: String,
|
||||
}
|
||||
|
||||
/// Installs the update.
|
||||
#[get("/updates/install")]
|
||||
pub async fn install_update(_token: APIToken) {
|
||||
let cloned_response_option = CHECK_UPDATE_RESPONSE.lock().unwrap().clone();
|
||||
match cloned_response_option {
|
||||
Some(update_response) => {
|
||||
update_response.download_and_install().await.unwrap();
|
||||
},
|
||||
|
||||
None => {
|
||||
error!(Source = "Updater"; "No update available to install. Did you check for updates first?");
|
||||
},
|
||||
}
|
||||
}
|
38
runtime/src/certificate.rs
Normal file
38
runtime/src/certificate.rs
Normal file
@ -0,0 +1,38 @@
|
||||
use std::sync::OnceLock;
|
||||
use log::info;
|
||||
use rcgen::generate_simple_self_signed;
|
||||
use sha2::{Sha256, Digest};
|
||||
|
||||
/// The certificate used for the runtime API server.
|
||||
pub static CERTIFICATE: OnceLock<Vec<u8>> = OnceLock::new();
|
||||
|
||||
/// The private key used for the certificate of the runtime API server.
|
||||
pub static CERTIFICATE_PRIVATE_KEY: OnceLock<Vec<u8>> = OnceLock::new();
|
||||
|
||||
/// The fingerprint of the certificate used for the runtime API server.
|
||||
pub static CERTIFICATE_FINGERPRINT: OnceLock<String> = OnceLock::new();
|
||||
|
||||
/// Generates a TLS certificate for the runtime API server.
|
||||
pub fn generate_certificate() {
|
||||
|
||||
info!("Try to generate a TLS certificate for the runtime API server...");
|
||||
|
||||
let subject_alt_names = vec!["localhost".to_string()];
|
||||
let certificate_data = generate_simple_self_signed(subject_alt_names).unwrap();
|
||||
let certificate_binary_data = certificate_data.cert.der().to_vec();
|
||||
|
||||
let certificate_fingerprint = Sha256::digest(certificate_binary_data).to_vec();
|
||||
let certificate_fingerprint = certificate_fingerprint.iter().fold(String::new(), |mut result, byte| {
|
||||
result.push_str(&format!("{:02x}", byte));
|
||||
result
|
||||
});
|
||||
|
||||
let certificate_fingerprint = certificate_fingerprint.to_uppercase();
|
||||
|
||||
CERTIFICATE_FINGERPRINT.set(certificate_fingerprint.clone()).expect("Could not set the certificate fingerprint.");
|
||||
CERTIFICATE.set(certificate_data.cert.pem().as_bytes().to_vec()).expect("Could not set the certificate.");
|
||||
CERTIFICATE_PRIVATE_KEY.set(certificate_data.key_pair.serialize_pem().as_bytes().to_vec()).expect("Could not set the private key.");
|
||||
|
||||
info!("Certificate fingerprint: '{certificate_fingerprint}'.");
|
||||
info!("Done generating certificate for the runtime API server.");
|
||||
}
|
62
runtime/src/clipboard.rs
Normal file
62
runtime/src/clipboard.rs
Normal file
@ -0,0 +1,62 @@
|
||||
use arboard::Clipboard;
|
||||
use log::{debug, error};
|
||||
use rocket::post;
|
||||
use rocket::serde::json::Json;
|
||||
use serde::Serialize;
|
||||
use crate::api_token::APIToken;
|
||||
use crate::encryption::{EncryptedText, ENCRYPTION};
|
||||
|
||||
/// Sets the clipboard text to the provided encrypted text.
|
||||
#[post("/clipboard/set", data = "<encrypted_text>")]
|
||||
pub fn set_clipboard(_token: APIToken, encrypted_text: EncryptedText) -> Json<SetClipboardResponse> {
|
||||
|
||||
// Decrypt this text first:
|
||||
let decrypted_text = match ENCRYPTION.decrypt(&encrypted_text) {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
error!(Source = "Clipboard"; "Failed to decrypt the text: {e}.");
|
||||
return Json(SetClipboardResponse {
|
||||
success: false,
|
||||
issue: e,
|
||||
})
|
||||
},
|
||||
};
|
||||
|
||||
let clipboard_result = Clipboard::new();
|
||||
let mut clipboard = match clipboard_result {
|
||||
Ok(clipboard) => clipboard,
|
||||
Err(e) => {
|
||||
error!(Source = "Clipboard"; "Failed to get the clipboard instance: {e}.");
|
||||
return Json(SetClipboardResponse {
|
||||
success: false,
|
||||
issue: e.to_string(),
|
||||
})
|
||||
},
|
||||
};
|
||||
|
||||
let set_text_result = clipboard.set_text(decrypted_text);
|
||||
match set_text_result {
|
||||
Ok(_) => {
|
||||
debug!(Source = "Clipboard"; "Text was set to the clipboard successfully.");
|
||||
Json(SetClipboardResponse {
|
||||
success: true,
|
||||
issue: String::from(""),
|
||||
})
|
||||
},
|
||||
|
||||
Err(e) => {
|
||||
error!(Source = "Clipboard"; "Failed to set text to the clipboard: {e}.");
|
||||
Json(SetClipboardResponse {
|
||||
success: false,
|
||||
issue: e.to_string(),
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The response for setting the clipboard text.
|
||||
#[derive(Serialize)]
|
||||
pub struct SetClipboardResponse {
|
||||
success: bool,
|
||||
issue: String,
|
||||
}
|
176
runtime/src/dotnet.rs
Normal file
176
runtime/src/dotnet.rs
Normal file
@ -0,0 +1,176 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use base64::Engine;
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
use log::{debug, error, info, warn};
|
||||
use once_cell::sync::Lazy;
|
||||
use rocket::get;
|
||||
use tauri::api::process::{Command, CommandChild, CommandEvent};
|
||||
use tauri::Url;
|
||||
use crate::api_token::{APIToken, API_TOKEN};
|
||||
use crate::app_window::change_location_to;
|
||||
use crate::certificate::CERTIFICATE_FINGERPRINT;
|
||||
use crate::encryption::ENCRYPTION;
|
||||
use crate::environment::is_dev;
|
||||
use crate::network::get_available_port;
|
||||
use crate::runtime_api::API_SERVER_PORT;
|
||||
|
||||
// The .NET server is started in a separate process and communicates with this
|
||||
// runtime process via IPC. However, we do net start the .NET server in
|
||||
// the development environment.
|
||||
static DOTNET_SERVER: Lazy<Arc<Mutex<Option<CommandChild>>>> = Lazy::new(|| Arc::new(Mutex::new(None)));
|
||||
|
||||
// The .NET server port is relevant for the production environment only, sine we
|
||||
// do not start the server in the development environment.
|
||||
static DOTNET_SERVER_PORT: Lazy<u16> = Lazy::new(|| get_available_port().unwrap());
|
||||
|
||||
static DOTNET_INITIALIZED: Lazy<Mutex<bool>> = Lazy::new(|| Mutex::new(false));
|
||||
|
||||
/// Returns the desired port of the .NET server. Our .NET app calls this endpoint to get
|
||||
/// the port where the .NET server should listen to.
|
||||
#[get("/system/dotnet/port")]
|
||||
pub fn dotnet_port(_token: APIToken) -> String {
|
||||
let dotnet_server_port = *DOTNET_SERVER_PORT;
|
||||
format!("{dotnet_server_port}")
|
||||
}
|
||||
|
||||
/// Starts the .NET server in a separate process.
|
||||
pub fn start_dotnet_server() {
|
||||
|
||||
// Get the secret password & salt and convert it to a base64 string:
|
||||
let secret_password = BASE64_STANDARD.encode(ENCRYPTION.secret_password);
|
||||
let secret_key_salt = BASE64_STANDARD.encode(ENCRYPTION.secret_key_salt);
|
||||
|
||||
let dotnet_server_environment = HashMap::from_iter([
|
||||
(String::from("AI_STUDIO_SECRET_PASSWORD"), secret_password),
|
||||
(String::from("AI_STUDIO_SECRET_KEY_SALT"), secret_key_salt),
|
||||
(String::from("AI_STUDIO_CERTIFICATE_FINGERPRINT"), CERTIFICATE_FINGERPRINT.get().unwrap().to_string()),
|
||||
(String::from("AI_STUDIO_API_TOKEN"), API_TOKEN.to_hex_text().to_string()),
|
||||
]);
|
||||
|
||||
info!("Try to start the .NET server...");
|
||||
let server_spawn_clone = DOTNET_SERVER.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let api_port = *API_SERVER_PORT;
|
||||
let (mut rx, child) = match is_dev() {
|
||||
true => {
|
||||
// We are in the development environment, so we try to start a process
|
||||
// with `dotnet run` in the `../app/MindWork AI Studio` directory. But
|
||||
// we cannot issue a sidecar because we cannot use any command for the
|
||||
// sidecar (see Tauri configuration). Thus, we use a standard Rust process:
|
||||
warn!(Source = "Bootloader .NET"; "Development environment detected; start .NET server using 'dotnet run'.");
|
||||
Command::new("dotnet")
|
||||
|
||||
// Start the .NET server in the `../app/MindWork AI Studio` directory.
|
||||
// We provide the runtime API server port to the .NET server:
|
||||
.args(["run", "--project", "../app/MindWork AI Studio", "--", format!("{api_port}").as_str()])
|
||||
|
||||
.envs(dotnet_server_environment)
|
||||
.spawn()
|
||||
.expect("Failed to spawn .NET server process.")
|
||||
}
|
||||
|
||||
false => {
|
||||
Command::new_sidecar("mindworkAIStudioServer")
|
||||
.expect("Failed to create sidecar")
|
||||
|
||||
// Provide the runtime API server port to the .NET server:
|
||||
.args([format!("{api_port}").as_str()])
|
||||
|
||||
.envs(dotnet_server_environment)
|
||||
.spawn()
|
||||
.expect("Failed to spawn .NET server process.")
|
||||
}
|
||||
};
|
||||
|
||||
let server_pid = child.pid();
|
||||
info!(Source = "Bootloader .NET"; "The .NET server process started with PID={server_pid}.");
|
||||
|
||||
// Save the server process to stop it later:
|
||||
*server_spawn_clone.lock().unwrap() = Some(child);
|
||||
|
||||
// Log the output of the .NET server:
|
||||
while let Some(CommandEvent::Stdout(line)) = rx.recv().await {
|
||||
|
||||
// Remove newline characters from the end:
|
||||
let line = line.trim_end();
|
||||
|
||||
// Starts the line with '=>'?
|
||||
if line.starts_with("=>") {
|
||||
// Yes. This means that the line is a log message from the .NET server.
|
||||
// The format is: '<YYYY-MM-dd HH:mm:ss.fff> [<log level>] <source>: <message>'.
|
||||
// We try to parse this line and log it with the correct log level:
|
||||
let line = line.trim_start_matches("=>").trim();
|
||||
let parts = line.split_once(": ").unwrap();
|
||||
let left_part = parts.0.trim();
|
||||
let message = parts.1.trim();
|
||||
let parts = left_part.split_once("] ").unwrap();
|
||||
let level = parts.0.split_once("[").unwrap().1.trim();
|
||||
let source = parts.1.trim();
|
||||
match level {
|
||||
"Trace" => debug!(Source = ".NET Server", Comp = source; "{message}"),
|
||||
"Debug" => debug!(Source = ".NET Server", Comp = source; "{message}"),
|
||||
"Information" => info!(Source = ".NET Server", Comp = source; "{message}"),
|
||||
"Warning" => warn!(Source = ".NET Server", Comp = source; "{message}"),
|
||||
"Error" => error!(Source = ".NET Server", Comp = source; "{message}"),
|
||||
"Critical" => error!(Source = ".NET Server", Comp = source; "{message}"),
|
||||
|
||||
_ => error!(Source = ".NET Server", Comp = source; "{message} (unknown log level '{level}')"),
|
||||
}
|
||||
} else {
|
||||
let lower_line = line.to_lowercase();
|
||||
if lower_line.contains("error") {
|
||||
error!(Source = ".NET Server"; "{line}");
|
||||
} else if lower_line.contains("warning") {
|
||||
warn!(Source = ".NET Server"; "{line}");
|
||||
} else {
|
||||
info!(Source = ".NET Server"; "{line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// This endpoint is called by the .NET server to signal that the server is ready.
|
||||
#[get("/system/dotnet/ready")]
|
||||
pub async fn dotnet_ready(_token: APIToken) {
|
||||
|
||||
// We create a manual scope for the lock to be released as soon as possible.
|
||||
// This is necessary because we cannot await any function while the lock is
|
||||
// held.
|
||||
{
|
||||
let mut initialized = DOTNET_INITIALIZED.lock().unwrap();
|
||||
if *initialized {
|
||||
error!("Anyone tried to initialize the runtime twice. This is not intended.");
|
||||
return;
|
||||
}
|
||||
|
||||
info!("The .NET server was booted successfully.");
|
||||
*initialized = true;
|
||||
}
|
||||
|
||||
let dotnet_server_port = *DOTNET_SERVER_PORT;
|
||||
let url = match Url::parse(format!("http://localhost:{dotnet_server_port}").as_str())
|
||||
{
|
||||
Ok(url) => url,
|
||||
Err(msg) => {
|
||||
error!("Error while parsing URL for navigating to the app: {msg}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
change_location_to(url.as_str()).await;
|
||||
}
|
||||
|
||||
/// Stops the .NET server process.
|
||||
pub fn stop_dotnet_server() {
|
||||
if let Some(server_process) = DOTNET_SERVER.lock().unwrap().take() {
|
||||
let server_kill_result = server_process.kill();
|
||||
match server_kill_result {
|
||||
Ok(_) => info!("The .NET server process was stopped."),
|
||||
Err(e) => error!("Failed to stop the .NET server process: {e}."),
|
||||
}
|
||||
} else {
|
||||
warn!("The .NET server process was not started or is already stopped.");
|
||||
}
|
||||
}
|
177
runtime/src/encryption.rs
Normal file
177
runtime/src/encryption.rs
Normal file
@ -0,0 +1,177 @@
|
||||
use std::fmt;
|
||||
use std::time::Instant;
|
||||
use base64::Engine;
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit};
|
||||
use hmac::Hmac;
|
||||
use log::info;
|
||||
use once_cell::sync::Lazy;
|
||||
use pbkdf2::pbkdf2;
|
||||
use rand::{RngCore, SeedableRng};
|
||||
use rocket::{data, Data, Request};
|
||||
use rocket::data::ToByteUnit;
|
||||
use rocket::http::Status;
|
||||
use rocket::serde::{Deserialize, Serialize};
|
||||
use sha2::Sha512;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
type Aes256CbcEnc = cbc::Encryptor<aes::Aes256>;
|
||||
|
||||
type Aes256CbcDec = cbc::Decryptor<aes::Aes256>;
|
||||
|
||||
type DataOutcome<'r, T> = data::Outcome<'r, T>;
|
||||
|
||||
/// The encryption instance used for the IPC channel.
|
||||
pub static ENCRYPTION: Lazy<Encryption> = Lazy::new(|| {
|
||||
//
|
||||
// Generate a secret key & salt for the AES encryption for the IPC channel:
|
||||
//
|
||||
let mut secret_key = [0u8; 512]; // 512 bytes = 4096 bits
|
||||
let mut secret_key_salt = [0u8; 16]; // 16 bytes = 128 bits
|
||||
|
||||
// We use a cryptographically secure pseudo-random number generator
|
||||
// to generate the secret password & salt. ChaCha20Rng is the algorithm
|
||||
// of our choice:
|
||||
let mut rng = rand_chacha::ChaChaRng::from_entropy();
|
||||
|
||||
// Fill the secret key & salt with random bytes:
|
||||
rng.fill_bytes(&mut secret_key);
|
||||
rng.fill_bytes(&mut secret_key_salt);
|
||||
|
||||
info!("Secret password for the IPC channel was generated successfully.");
|
||||
Encryption::new(&secret_key, &secret_key_salt).unwrap()
|
||||
});
|
||||
|
||||
/// The encryption struct used for the IPC channel.
|
||||
pub struct Encryption {
|
||||
key: [u8; 32],
|
||||
iv: [u8; 16],
|
||||
|
||||
pub secret_password: [u8; 512],
|
||||
pub secret_key_salt: [u8; 16],
|
||||
}
|
||||
|
||||
impl Encryption {
|
||||
// The number of iterations to derive the key and IV from the password. For a password
|
||||
// manager where the user has to enter their primary password, 100 iterations would be
|
||||
// too few and insecure. Here, the use case is different: We generate a 512-byte long
|
||||
// and cryptographically secure password at every start. This password already contains
|
||||
// enough entropy. In our case, we need key and IV primarily because AES, with the
|
||||
// algorithms we chose, requires a fixed key length, and our password is too long.
|
||||
const ITERATIONS: u32 = 100;
|
||||
|
||||
/// Initializes the encryption with the given secret password and salt.
|
||||
pub fn new(secret_password: &[u8], secret_key_salt: &[u8]) -> Result<Self, String> {
|
||||
if secret_password.len() != 512 {
|
||||
return Err("The secret password must be 512 bytes long.".to_string());
|
||||
}
|
||||
|
||||
if secret_key_salt.len() != 16 {
|
||||
return Err("The salt must be 16 bytes long.".to_string());
|
||||
}
|
||||
|
||||
info!(Source = "Encryption"; "Initializing encryption...");
|
||||
let mut encryption = Encryption {
|
||||
key: [0u8; 32],
|
||||
iv: [0u8; 16],
|
||||
|
||||
secret_password: [0u8; 512],
|
||||
secret_key_salt: [0u8; 16],
|
||||
};
|
||||
|
||||
encryption.secret_password.copy_from_slice(secret_password);
|
||||
encryption.secret_key_salt.copy_from_slice(secret_key_salt);
|
||||
|
||||
let start = Instant::now();
|
||||
let mut key_iv = [0u8; 48];
|
||||
pbkdf2::<Hmac<Sha512>>(secret_password, secret_key_salt, Self::ITERATIONS, &mut key_iv).map_err(|e| format!("Error while generating key and IV: {e}"))?;
|
||||
encryption.key.copy_from_slice(&key_iv[0..32]);
|
||||
encryption.iv.copy_from_slice(&key_iv[32..48]);
|
||||
|
||||
let duration = start.elapsed();
|
||||
let duration = duration.as_millis();
|
||||
info!(Source = "Encryption"; "Encryption initialized in {duration} milliseconds.", );
|
||||
|
||||
Ok(encryption)
|
||||
}
|
||||
|
||||
/// Encrypts the given data.
|
||||
pub fn encrypt(&self, data: &str) -> Result<EncryptedText, String> {
|
||||
let cipher = Aes256CbcEnc::new(&self.key.into(), &self.iv.into());
|
||||
let encrypted = cipher.encrypt_padded_vec_mut::<Pkcs7>(data.as_bytes());
|
||||
let mut result = BASE64_STANDARD.encode(self.secret_key_salt);
|
||||
result.push_str(&BASE64_STANDARD.encode(&encrypted));
|
||||
Ok(EncryptedText::new(result))
|
||||
}
|
||||
|
||||
/// Decrypts the given data.
|
||||
pub fn decrypt(&self, encrypted_data: &EncryptedText) -> Result<String, String> {
|
||||
let decoded = BASE64_STANDARD.decode(encrypted_data.get_encrypted()).map_err(|e| format!("Error decoding base64: {e}"))?;
|
||||
|
||||
if decoded.len() < 16 {
|
||||
return Err("Encrypted data is too short.".to_string());
|
||||
}
|
||||
|
||||
let (salt, encrypted) = decoded.split_at(16);
|
||||
if salt != self.secret_key_salt {
|
||||
return Err("The salt bytes do not match. The data is corrupted or tampered.".to_string());
|
||||
}
|
||||
|
||||
let cipher = Aes256CbcDec::new(&self.key.into(), &self.iv.into());
|
||||
let decrypted = cipher.decrypt_padded_vec_mut::<Pkcs7>(encrypted).map_err(|e| format!("Error decrypting data: {e}"))?;
|
||||
|
||||
String::from_utf8(decrypted).map_err(|e| format!("Error converting decrypted data to string: {}", e))
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents encrypted text.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct EncryptedText(String);
|
||||
|
||||
impl EncryptedText {
|
||||
|
||||
/// Creates a new encrypted text instance.
|
||||
pub fn new(encrypted_data: String) -> Self {
|
||||
EncryptedText(encrypted_data)
|
||||
}
|
||||
|
||||
/// Returns the encrypted data.
|
||||
pub fn get_encrypted(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for EncryptedText {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "EncryptedText(**********)")
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for EncryptedText {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "**********")
|
||||
}
|
||||
}
|
||||
|
||||
/// Use Case: When we receive encrypted text from the client as body (e.g., in a POST request).
|
||||
/// We must interpret the body as EncryptedText.
|
||||
#[rocket::async_trait]
|
||||
impl<'r> data::FromData<'r> for EncryptedText {
|
||||
type Error = String;
|
||||
|
||||
/// Parses the data as EncryptedText.
|
||||
async fn from_data(req: &'r Request<'_>, data: Data<'r>) -> DataOutcome<'r, Self> {
|
||||
let content_type = req.content_type();
|
||||
if content_type.map_or(true, |ct| !ct.is_text()) {
|
||||
return DataOutcome::Forward((data, Status::Ok));
|
||||
}
|
||||
|
||||
let mut stream = data.open(2.mebibytes());
|
||||
let mut body = String::new();
|
||||
if let Err(e) = stream.read_to_string(&mut body).await {
|
||||
return DataOutcome::Error((Status::InternalServerError, format!("Failed to read data: {}", e)));
|
||||
}
|
||||
|
||||
DataOutcome::Success(EncryptedText(body))
|
||||
}
|
||||
}
|
37
runtime/src/environment.rs
Normal file
37
runtime/src/environment.rs
Normal file
@ -0,0 +1,37 @@
|
||||
use std::sync::OnceLock;
|
||||
use rocket::get;
|
||||
use crate::api_token::APIToken;
|
||||
|
||||
/// The data directory where the application stores its data.
|
||||
pub static DATA_DIRECTORY: OnceLock<String> = OnceLock::new();
|
||||
|
||||
/// The config directory where the application stores its configuration.
|
||||
pub static CONFIG_DIRECTORY: OnceLock<String> = OnceLock::new();
|
||||
|
||||
/// Returns the config directory.
|
||||
#[get("/system/directories/config")]
|
||||
pub fn get_config_directory(_token: APIToken) -> String {
|
||||
match CONFIG_DIRECTORY.get() {
|
||||
Some(config_directory) => config_directory.clone(),
|
||||
None => String::from(""),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the data directory.
|
||||
#[get("/system/directories/data")]
|
||||
pub fn get_data_directory(_token: APIToken) -> String {
|
||||
match DATA_DIRECTORY.get() {
|
||||
Some(data_directory) => data_directory.clone(),
|
||||
None => String::from(""),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the application is running in development mode.
|
||||
pub fn is_dev() -> bool {
|
||||
cfg!(debug_assertions)
|
||||
}
|
||||
|
||||
/// Returns true if the application is running in production mode.
|
||||
pub fn is_prod() -> bool {
|
||||
!is_dev()
|
||||
}
|
11
runtime/src/lib.rs
Normal file
11
runtime/src/lib.rs
Normal file
@ -0,0 +1,11 @@
|
||||
pub mod encryption;
|
||||
pub mod log;
|
||||
pub mod environment;
|
||||
pub mod dotnet;
|
||||
pub mod network;
|
||||
pub mod api_token;
|
||||
pub mod app_window;
|
||||
pub mod secret;
|
||||
pub mod clipboard;
|
||||
pub mod runtime_api;
|
||||
pub mod certificate;
|
163
runtime/src/log.rs
Normal file
163
runtime/src/log.rs
Normal file
@ -0,0 +1,163 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::error::Error;
|
||||
use std::fmt::Debug;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
use flexi_logger::{DeferredNow, Duplicate, FileSpec, Logger, LoggerHandle};
|
||||
use flexi_logger::writers::FileLogWriter;
|
||||
use log::kv;
|
||||
use log::kv::{Key, Value, VisitSource};
|
||||
use crate::environment::is_dev;
|
||||
|
||||
static LOGGER: OnceLock<RuntimeLoggerHandle> = OnceLock::new();
|
||||
|
||||
/// Initialize the logging system.
|
||||
pub fn init_logging() {
|
||||
|
||||
//
|
||||
// Configure the LOGGER:
|
||||
//
|
||||
let mut log_config = String::new();
|
||||
|
||||
// Set the log level depending on the environment:
|
||||
match is_dev() {
|
||||
true => log_config.push_str("debug, "),
|
||||
false => log_config.push_str("info, "),
|
||||
};
|
||||
|
||||
// Set the log level for the Rocket library:
|
||||
log_config.push_str("rocket=info, ");
|
||||
|
||||
// Set the log level for the Rocket server:
|
||||
log_config.push_str("rocket::server=warn, ");
|
||||
|
||||
// Set the log level for the Reqwest library:
|
||||
log_config.push_str("reqwest::async_impl::client=info");
|
||||
|
||||
// Configure the initial filename. On Unix systems, the file should start
|
||||
// with a dot to be hidden.
|
||||
let log_basename = match cfg!(unix)
|
||||
{
|
||||
true => ".AI Studio Events",
|
||||
false => "AI Studio Events",
|
||||
};
|
||||
|
||||
let runtime_logger = Logger::try_with_str(log_config).expect("Cannot create logging")
|
||||
.log_to_file(FileSpec::default()
|
||||
.basename(log_basename)
|
||||
.suppress_timestamp()
|
||||
.suffix("log"))
|
||||
.duplicate_to_stdout(Duplicate::All)
|
||||
.use_utc()
|
||||
.format_for_files(file_logger_format)
|
||||
.format_for_stderr(terminal_colored_logger_format)
|
||||
.format_for_stdout(terminal_colored_logger_format)
|
||||
.start().expect("Cannot start logging");
|
||||
|
||||
let runtime_logger = RuntimeLoggerHandle{
|
||||
handle: runtime_logger
|
||||
};
|
||||
|
||||
LOGGER.set(runtime_logger).expect("Cannot set LOGGER");
|
||||
}
|
||||
|
||||
/// Switch the logging system to a file-based output.
|
||||
pub fn switch_to_file_logging(logger_path: PathBuf) -> Result<(), Box<dyn Error>>{
|
||||
LOGGER.get().expect("No LOGGER was set").handle.reset_flw(&FileLogWriter::builder(
|
||||
FileSpec::default()
|
||||
.directory(logger_path)
|
||||
.basename("events")
|
||||
.suppress_timestamp()
|
||||
.suffix("log")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct RuntimeLoggerHandle {
|
||||
handle: LoggerHandle
|
||||
}
|
||||
|
||||
impl Debug for RuntimeLoggerHandle {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "LoggerHandle")
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Data structure for iterating over key-value pairs of log messages.
|
||||
//
|
||||
struct LogKVCollect<'kvs>(BTreeMap<Key<'kvs>, Value<'kvs>>);
|
||||
|
||||
impl<'kvs> VisitSource<'kvs> for LogKVCollect<'kvs> {
|
||||
fn visit_pair(&mut self, key: Key<'kvs>, value: Value<'kvs>) -> Result<(), kv::Error> {
|
||||
self.0.insert(key, value);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn write_kv_pairs(w: &mut dyn std::io::Write, record: &log::Record) -> Result<(), std::io::Error> {
|
||||
if record.key_values().count() > 0 {
|
||||
let mut visitor = LogKVCollect(BTreeMap::new());
|
||||
record.key_values().visit(&mut visitor).unwrap();
|
||||
write!(w, "[")?;
|
||||
let mut index = 0;
|
||||
for (key, value) in visitor.0 {
|
||||
index += 1;
|
||||
if index > 1 {
|
||||
write!(w, ", ")?;
|
||||
}
|
||||
|
||||
write!(w, "{} = {}", key, value)?;
|
||||
}
|
||||
write!(w, "] ")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Custom LOGGER format for the terminal:
|
||||
fn terminal_colored_logger_format(
|
||||
w: &mut dyn std::io::Write,
|
||||
now: &mut DeferredNow,
|
||||
record: &log::Record,
|
||||
) -> Result<(), std::io::Error> {
|
||||
let level = record.level();
|
||||
|
||||
// Write the timestamp, log level, and module path:
|
||||
write!(
|
||||
w,
|
||||
"[{}] {} [{}] ",
|
||||
flexi_logger::style(level).paint(now.format(flexi_logger::TS_DASHES_BLANK_COLONS_DOT_BLANK).to_string()),
|
||||
flexi_logger::style(level).paint(record.level().to_string()),
|
||||
record.module_path().unwrap_or("<unnamed>"),
|
||||
)?;
|
||||
|
||||
// Write all key-value pairs:
|
||||
write_kv_pairs(w, record)?;
|
||||
|
||||
// Write the log message:
|
||||
write!(w, "{}", flexi_logger::style(level).paint(record.args().to_string()))
|
||||
}
|
||||
|
||||
/// Custom LOGGER format for the log files:
|
||||
fn file_logger_format(
|
||||
w: &mut dyn std::io::Write,
|
||||
now: &mut DeferredNow,
|
||||
record: &log::Record,
|
||||
) -> Result<(), std::io::Error> {
|
||||
|
||||
// Write the timestamp, log level, and module path:
|
||||
write!(
|
||||
w,
|
||||
"[{}] {} [{}] ",
|
||||
now.format(flexi_logger::TS_DASHES_BLANK_COLONS_DOT_BLANK),
|
||||
record.level(),
|
||||
record.module_path().unwrap_or("<unnamed>"),
|
||||
)?;
|
||||
|
||||
// Write all key-value pairs:
|
||||
write_kv_pairs(w, record)?;
|
||||
|
||||
// Write the log message:
|
||||
write!(w, "{}", &record.args())
|
||||
}
|
1063
runtime/src/main.rs
1063
runtime/src/main.rs
File diff suppressed because it is too large
Load Diff
8
runtime/src/network.rs
Normal file
8
runtime/src/network.rs
Normal file
@ -0,0 +1,8 @@
|
||||
use std::net::TcpListener;
|
||||
|
||||
/// Returns an available port on the local machine.
|
||||
pub fn get_available_port() -> Option<u16> {
|
||||
TcpListener::bind(("127.0.0.1", 0))
|
||||
.map(|listener| listener.local_addr().unwrap().port())
|
||||
.ok()
|
||||
}
|
96
runtime/src/runtime_api.rs
Normal file
96
runtime/src/runtime_api.rs
Normal file
@ -0,0 +1,96 @@
|
||||
use std::collections::HashSet;
|
||||
use log::info;
|
||||
use once_cell::sync::Lazy;
|
||||
use rocket::config::Shutdown;
|
||||
use rocket::figment::Figment;
|
||||
use rocket::routes;
|
||||
use crate::certificate::{CERTIFICATE, CERTIFICATE_PRIVATE_KEY};
|
||||
use crate::environment::is_dev;
|
||||
use crate::network::get_available_port;
|
||||
|
||||
/// The port used for the runtime API server. In the development environment, we use a fixed
|
||||
/// port, in the production environment we use the next available port. This differentiation
|
||||
/// is necessary because we cannot communicate the port to the .NET server in the development
|
||||
/// environment.
|
||||
pub static API_SERVER_PORT: Lazy<u16> = Lazy::new(|| {
|
||||
if is_dev() {
|
||||
5000
|
||||
} else {
|
||||
get_available_port().unwrap()
|
||||
}
|
||||
});
|
||||
|
||||
/// Starts the runtime API server. The server is used to communicate with the .NET server and
|
||||
/// to provide additional functionality to the Tauri app.
|
||||
pub fn start_runtime_api() {
|
||||
let api_port = *API_SERVER_PORT;
|
||||
info!("Try to start the API server on 'http://localhost:{api_port}'...");
|
||||
|
||||
// The shutdown configuration for the runtime API server:
|
||||
let mut shutdown = Shutdown {
|
||||
// We do not want to use the Ctrl+C signal to stop the server:
|
||||
ctrlc: false,
|
||||
|
||||
// Everything else is set to default for now:
|
||||
..Shutdown::default()
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// We do not want to use the termination signal to stop the server.
|
||||
// This option, however, is only available on Unix systems:
|
||||
shutdown.signals = HashSet::new();
|
||||
}
|
||||
|
||||
// Configure the runtime API server:
|
||||
let figment = Figment::from(rocket::Config::release_default())
|
||||
|
||||
// We use the next available port which was determined before:
|
||||
.merge(("port", api_port))
|
||||
|
||||
// The runtime API server should be accessible only from the local machine:
|
||||
.merge(("address", "127.0.0.1"))
|
||||
|
||||
// We do not want to use the Ctrl+C signal to stop the server:
|
||||
.merge(("ctrlc", false))
|
||||
|
||||
// Set a name for the server:
|
||||
.merge(("ident", "AI Studio Runtime API"))
|
||||
|
||||
// Set the maximum number of workers and blocking threads:
|
||||
.merge(("workers", 3))
|
||||
.merge(("max_blocking", 12))
|
||||
|
||||
// No colors and emojis in the log output:
|
||||
.merge(("cli_colors", false))
|
||||
|
||||
// Read the TLS certificate and key from the generated certificate data in-memory:
|
||||
.merge(("tls.certs", CERTIFICATE.get().unwrap()))
|
||||
.merge(("tls.key", CERTIFICATE_PRIVATE_KEY.get().unwrap()))
|
||||
|
||||
// Set the shutdown configuration:
|
||||
.merge(("shutdown", shutdown));
|
||||
|
||||
//
|
||||
// Start the runtime API server in a separate thread. This is necessary
|
||||
// because the server is blocking, and we need to run the Tauri app in
|
||||
// parallel:
|
||||
//
|
||||
tauri::async_runtime::spawn(async move {
|
||||
rocket::custom(figment)
|
||||
.mount("/", routes![
|
||||
crate::dotnet::dotnet_port,
|
||||
crate::dotnet::dotnet_ready,
|
||||
crate::clipboard::set_clipboard,
|
||||
crate::app_window::check_for_update,
|
||||
crate::app_window::install_update,
|
||||
crate::secret::get_secret,
|
||||
crate::secret::store_secret,
|
||||
crate::secret::delete_secret,
|
||||
crate::environment::get_data_directory,
|
||||
crate::environment::get_config_directory,
|
||||
])
|
||||
.ignite().await.unwrap()
|
||||
.launch().await.unwrap();
|
||||
});
|
||||
}
|
167
runtime/src/secret.rs
Normal file
167
runtime/src/secret.rs
Normal file
@ -0,0 +1,167 @@
|
||||
use keyring::Entry;
|
||||
use log::{error, info, warn};
|
||||
use rocket::post;
|
||||
use rocket::serde::json::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use keyring::error::Error::NoEntry;
|
||||
use crate::api_token::APIToken;
|
||||
use crate::encryption::{EncryptedText, ENCRYPTION};
|
||||
|
||||
/// Stores a secret in the secret store using the operating system's keyring.
|
||||
#[post("/secrets/store", data = "<request>")]
|
||||
pub fn store_secret(_token: APIToken, request: Json<StoreSecret>) -> Json<StoreSecretResponse> {
|
||||
let user_name = request.user_name.as_str();
|
||||
let decrypted_text = match ENCRYPTION.decrypt(&request.secret) {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
error!(Source = "Secret Store"; "Failed to decrypt the text: {e}.");
|
||||
return Json(StoreSecretResponse {
|
||||
success: false,
|
||||
issue: format!("Failed to decrypt the text: {e}"),
|
||||
})
|
||||
},
|
||||
};
|
||||
|
||||
let service = format!("mindwork-ai-studio::{}", request.destination);
|
||||
let entry = Entry::new(service.as_str(), user_name).unwrap();
|
||||
let result = entry.set_password(decrypted_text.as_str());
|
||||
match result {
|
||||
Ok(_) => {
|
||||
info!(Source = "Secret Store"; "Secret for {service} and user {user_name} was stored successfully.");
|
||||
Json(StoreSecretResponse {
|
||||
success: true,
|
||||
issue: String::from(""),
|
||||
})
|
||||
},
|
||||
|
||||
Err(e) => {
|
||||
error!(Source = "Secret Store"; "Failed to store secret for {service} and user {user_name}: {e}.");
|
||||
Json(StoreSecretResponse {
|
||||
success: false,
|
||||
issue: e.to_string(),
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The structure of the request to store a secret.
|
||||
#[derive(Deserialize)]
|
||||
pub struct StoreSecret {
|
||||
destination: String,
|
||||
user_name: String,
|
||||
secret: EncryptedText,
|
||||
}
|
||||
|
||||
/// The structure of the response to storing a secret.
|
||||
#[derive(Serialize)]
|
||||
pub struct StoreSecretResponse {
|
||||
success: bool,
|
||||
issue: String,
|
||||
}
|
||||
|
||||
/// Retrieves a secret from the secret store using the operating system's keyring.
|
||||
#[post("/secrets/get", data = "<request>")]
|
||||
pub fn get_secret(_token: APIToken, request: Json<RequestSecret>) -> Json<RequestedSecret> {
|
||||
let user_name = request.user_name.as_str();
|
||||
let service = format!("mindwork-ai-studio::{}", request.destination);
|
||||
let entry = Entry::new(service.as_str(), user_name).unwrap();
|
||||
let secret = entry.get_password();
|
||||
match secret {
|
||||
Ok(s) => {
|
||||
info!(Source = "Secret Store"; "Secret for '{service}' and user '{user_name}' was retrieved successfully.");
|
||||
|
||||
// Encrypt the secret:
|
||||
let encrypted_secret = match ENCRYPTION.encrypt(s.as_str()) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
error!(Source = "Secret Store"; "Failed to encrypt the secret: {e}.");
|
||||
return Json(RequestedSecret {
|
||||
success: false,
|
||||
secret: EncryptedText::new(String::from("")),
|
||||
issue: format!("Failed to encrypt the secret: {e}"),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
Json(RequestedSecret {
|
||||
success: true,
|
||||
secret: encrypted_secret,
|
||||
issue: String::from(""),
|
||||
})
|
||||
},
|
||||
|
||||
Err(e) => {
|
||||
if !request.is_trying {
|
||||
error!(Source = "Secret Store"; "Failed to retrieve secret for '{service}' and user '{user_name}': {e}.");
|
||||
}
|
||||
|
||||
Json(RequestedSecret {
|
||||
success: false,
|
||||
secret: EncryptedText::new(String::from("")),
|
||||
issue: format!("Failed to retrieve secret for '{service}' and user '{user_name}': {e}"),
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The structure of the request to retrieve a secret.
|
||||
#[derive(Deserialize)]
|
||||
pub struct RequestSecret {
|
||||
destination: String,
|
||||
user_name: String,
|
||||
is_trying: bool,
|
||||
}
|
||||
|
||||
/// The structure of the response to retrieving a secret.
|
||||
#[derive(Serialize)]
|
||||
pub struct RequestedSecret {
|
||||
success: bool,
|
||||
secret: EncryptedText,
|
||||
issue: String,
|
||||
}
|
||||
|
||||
/// Deletes a secret from the secret store using the operating system's keyring.
|
||||
#[post("/secrets/delete", data = "<request>")]
|
||||
pub fn delete_secret(_token: APIToken, request: Json<RequestSecret>) -> Json<DeleteSecretResponse> {
|
||||
let user_name = request.user_name.as_str();
|
||||
let service = format!("mindwork-ai-studio::{}", request.destination);
|
||||
let entry = Entry::new(service.as_str(), user_name).unwrap();
|
||||
let result = entry.delete_credential();
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
warn!(Source = "Secret Store"; "Secret for {service} and user {user_name} was deleted successfully.");
|
||||
Json(DeleteSecretResponse {
|
||||
success: true,
|
||||
was_entry_found: true,
|
||||
issue: String::from(""),
|
||||
})
|
||||
},
|
||||
|
||||
Err(NoEntry) => {
|
||||
warn!(Source = "Secret Store"; "No secret for {service} and user {user_name} was found.");
|
||||
Json(DeleteSecretResponse {
|
||||
success: true,
|
||||
was_entry_found: false,
|
||||
issue: String::from(""),
|
||||
})
|
||||
}
|
||||
|
||||
Err(e) => {
|
||||
error!(Source = "Secret Store"; "Failed to delete secret for {service} and user {user_name}: {e}.");
|
||||
Json(DeleteSecretResponse {
|
||||
success: false,
|
||||
was_entry_found: false,
|
||||
issue: e.to_string(),
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The structure of the response to deleting a secret.
|
||||
#[derive(Serialize)]
|
||||
pub struct DeleteSecretResponse {
|
||||
success: bool,
|
||||
was_entry_found: bool,
|
||||
issue: String,
|
||||
}
|
Loading…
Reference in New Issue
Block a user