feat: hoppscotch agent and agent interceptor (#4396)
Co-authored-by: CuriousCorrelation <CuriousCorrelation@gmail.com> Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
7
packages/hoppscotch-agent/src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
6070
packages/hoppscotch-agent/src-tauri/Cargo.lock
generated
Normal file
49
packages/hoppscotch-agent/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,49 @@
|
||||
[package]
|
||||
name = "hoppscotch-agent"
|
||||
version = "0.1.0"
|
||||
description = "A cross-platform HTTP request agent for Hoppscotch for advanced request handling including custom headers, certificates, proxies, and local system integration."
|
||||
authors = ["CuriousCorrelation", "AndrewBastin"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
name = "hoppscotch_agent_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.0.0-rc", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.0.0-rc.0", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-shell = "2.0.0-rc"
|
||||
tauri-plugin-autostart = "2.0.0-rc"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1.40.0", features = ["full"] }
|
||||
dashmap = { version = "6.1.0", features = ["serde"] }
|
||||
axum = { version = "0.7.6" }
|
||||
axum-extra = { version = "0.9.4", features = ["typed-header"] }
|
||||
tower-http = { version = "0.6.1", features = ["cors"] }
|
||||
tokio-util = "0.7.12"
|
||||
uuid = { version = "1.10.0", features = [ "v4", "fast-rng" ] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
rand = "0.8.5"
|
||||
log = "0.4.22"
|
||||
env_logger = "0.11.5"
|
||||
curl = { version = "0.4.46", features = ["ntlm", "static-curl", "static-ssl"] }
|
||||
openssl = { version = "0.10.66", features = ["vendored"] }
|
||||
openssl-sys = { version = "0.9.103", features = ["vendored"] }
|
||||
url-escape = "0.1.1"
|
||||
thiserror = "1.0.64"
|
||||
tauri-plugin-store = "2.0.0-rc.3"
|
||||
x25519-dalek = { version = "2.0.1", features = ["getrandom"] }
|
||||
base16 = "0.2.1"
|
||||
aes-gcm = { version = "0.10.3", features = ["aes"] }
|
||||
tauri-plugin-updater = "2.0.0-rc.3"
|
||||
tauri-plugin-dialog = "2.0.0-rc.7"
|
||||
http = "1.1.0"
|
||||
lazy_static = "1.5.0"
|
||||
|
||||
[dev-dependencies]
|
||||
mockito = "1.5.0"
|
||||
5
packages/hoppscotch-agent/src-tauri/build.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
fn main() {
|
||||
tauri_build::build();
|
||||
println!("cargo::rerun-if-env-changed=UPDATER_PUB_KEY");
|
||||
println!("cargo::rerun-if-env-changed=UPDATER_URL");
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main", "test"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:allow-open",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-set-always-on-top"
|
||||
]
|
||||
}
|
||||
BIN
packages/hoppscotch-agent/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
packages/hoppscotch-agent/src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
packages/hoppscotch-agent/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
packages/hoppscotch-agent/src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
packages/hoppscotch-agent/src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
packages/hoppscotch-agent/src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
packages/hoppscotch-agent/src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
packages/hoppscotch-agent/src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
packages/hoppscotch-agent/src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
packages/hoppscotch-agent/src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
packages/hoppscotch-agent/src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
packages/hoppscotch-agent/src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
packages/hoppscotch-agent/src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 23 KiB |
BIN
packages/hoppscotch-agent/src-tauri/icons/icon.icns
Normal file
BIN
packages/hoppscotch-agent/src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
packages/hoppscotch-agent/src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 948 B |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 12 KiB |
BIN
packages/hoppscotch-agent/src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 545 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 19 KiB |
BIN
packages/hoppscotch-agent/src-tauri/icons/tray_icon.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
219
packages/hoppscotch-agent/src-tauri/src/controller.rs
Normal file
@@ -0,0 +1,219 @@
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{Path, State},
|
||||
http::HeaderMap,
|
||||
Json,
|
||||
};
|
||||
use axum_extra::{
|
||||
headers::{authorization::Bearer, Authorization},
|
||||
TypedHeader,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use x25519_dalek::{EphemeralSecret, PublicKey};
|
||||
|
||||
use crate::{
|
||||
error::{AppError, AppResult},
|
||||
model::{
|
||||
AuthKeyResponse, ConfirmedRegistrationRequest, HandshakeResponse, RequestDef,
|
||||
RunRequestResponse,
|
||||
},
|
||||
state::{AppState, Registration},
|
||||
util::EncryptedJson,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use rand::Rng;
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn generate_otp() -> String {
|
||||
let otp: u32 = rand::thread_rng().gen_range(0..1_000_000);
|
||||
|
||||
format!("{:06}", otp)
|
||||
}
|
||||
|
||||
pub async fn handshake(
|
||||
State((_, app_handle)): State<(Arc<AppState>, AppHandle)>
|
||||
) -> AppResult<Json<HandshakeResponse>> {
|
||||
Ok(Json(HandshakeResponse {
|
||||
status: "success".to_string(),
|
||||
__hoppscotch__agent__: true,
|
||||
agent_version: app_handle.package_info().version.to_string()
|
||||
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn receive_registration(
|
||||
State((state, app_handle)): State<(Arc<AppState>, AppHandle)>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let otp = generate_otp();
|
||||
|
||||
let mut active_registration_code = state.active_registration_code.write().await;
|
||||
|
||||
if !active_registration_code.is_none() {
|
||||
return Ok(Json(
|
||||
json!({ "message": "There is already an existing registration happening" }),
|
||||
));
|
||||
}
|
||||
|
||||
*active_registration_code = Some(otp.clone());
|
||||
|
||||
app_handle
|
||||
.emit("registration_received", otp)
|
||||
.map_err(|_| AppError::InternalServerError)?;
|
||||
|
||||
Ok(Json(
|
||||
json!({ "message": "Registration received and stored" }),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn verify_registration(
|
||||
State((state, app_handle)): State<(Arc<AppState>, AppHandle)>,
|
||||
Json(confirmed_registration): Json<ConfirmedRegistrationRequest>,
|
||||
) -> AppResult<Json<AuthKeyResponse>> {
|
||||
state
|
||||
.validate_registration(&confirmed_registration.registration)
|
||||
.await
|
||||
.then_some(())
|
||||
.ok_or(AppError::InvalidRegistration)?;
|
||||
|
||||
let auth_key = Uuid::new_v4().to_string();
|
||||
let created_at = Utc::now();
|
||||
|
||||
let auth_key_copy = auth_key.clone();
|
||||
|
||||
let agent_secret_key = EphemeralSecret::random();
|
||||
let agent_public_key = PublicKey::from(&agent_secret_key);
|
||||
|
||||
let their_public_key = {
|
||||
let public_key_slice: &[u8; 32] = &base16::decode(&confirmed_registration.client_public_key_b16)
|
||||
.map_err(|_| AppError::InvalidClientPublicKey)?
|
||||
[0..32]
|
||||
.try_into()
|
||||
.map_err(|_| AppError::InvalidClientPublicKey)?;
|
||||
|
||||
PublicKey::from(public_key_slice.to_owned())
|
||||
};
|
||||
|
||||
let shared_secret = agent_secret_key.diffie_hellman(&their_public_key);
|
||||
|
||||
let _ = state.update_registrations(app_handle.clone(), |regs| {
|
||||
regs.insert(auth_key_copy, Registration {
|
||||
registered_at: created_at,
|
||||
shared_secret_b16: base16::encode_lower(shared_secret.as_bytes())
|
||||
});
|
||||
})?;
|
||||
|
||||
let auth_payload = json!({
|
||||
"auth_key": auth_key,
|
||||
"created_at": created_at
|
||||
});
|
||||
|
||||
app_handle
|
||||
.emit("authenticated", &auth_payload)
|
||||
.map_err(|_| AppError::InternalServerError)?;
|
||||
|
||||
Ok(Json(AuthKeyResponse {
|
||||
auth_key,
|
||||
created_at,
|
||||
agent_public_key_b16: base16::encode_lower(agent_public_key.as_bytes()),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn run_request<T>(
|
||||
State((state, _app_handle)): State<(Arc<AppState>, T)>,
|
||||
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes
|
||||
) -> AppResult<EncryptedJson<RunRequestResponse>> {
|
||||
let nonce = headers.get("X-Hopp-Nonce")
|
||||
.ok_or(AppError::Unauthorized)?
|
||||
.to_str()
|
||||
.map_err(|_| AppError::Unauthorized)?;
|
||||
|
||||
let req: RequestDef = state.validate_access_and_get_data(auth_header.token(), nonce, &body)
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
|
||||
let reg_info = state.get_registration_info(auth_header.token())
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
|
||||
let cancel_token = tokio_util::sync::CancellationToken::new();
|
||||
state.add_cancellation_token(req.req_id, cancel_token.clone());
|
||||
|
||||
let req_id = req.req_id;
|
||||
let cancel_token_clone = cancel_token.clone();
|
||||
|
||||
// Execute the HTTP request in a blocking thread pool and handles cancellation.
|
||||
//
|
||||
// It:
|
||||
// 1. Uses `spawn_blocking` to run the sync `run_request_task`
|
||||
// without blocking the main Tokio runtime.
|
||||
// 2. Uses `select!` to concurrently wait for either
|
||||
// a. the task to complete,
|
||||
// b. or a cancellation signal.
|
||||
//
|
||||
// Why spawn_blocking?
|
||||
// - `run_request_task` uses synchronous curl operations which would block
|
||||
// the async runtime if not run in a separate thread.
|
||||
// - `spawn_blocking` moves this operation to a thread pool designed for
|
||||
// blocking tasks, so other async operations to continue unblocked.
|
||||
let result = tokio::select! {
|
||||
res = tokio::task::spawn_blocking(move || crate::interceptor::run_request_task(&req, cancel_token_clone)) => {
|
||||
match res {
|
||||
Ok(task_result) => task_result,
|
||||
Err(_) => Err(AppError::InternalServerError),
|
||||
}
|
||||
},
|
||||
_ = cancel_token.cancelled() => {
|
||||
Err(AppError::RequestCancelled)
|
||||
}
|
||||
};
|
||||
|
||||
state.remove_cancellation_token(req_id);
|
||||
|
||||
result.map(|val| {
|
||||
EncryptedJson {
|
||||
key_b16: reg_info.shared_secret_b16,
|
||||
data: val
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Provides a way for registered clients to check if their
|
||||
/// registration still holds, this route is supposed to return
|
||||
/// an encrypted `true` value if the given auth_key is good.
|
||||
/// Since its encrypted with the shared secret established during
|
||||
/// registration, the client also needs the shared secret to verify
|
||||
/// if the read fails, or the auth_key didn't validate and this route returns
|
||||
/// undefined, we can count on the registration not being valid anymore.
|
||||
pub async fn registered_handshake(
|
||||
State((state, _)): State<(Arc<AppState>, AppHandle)>,
|
||||
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
|
||||
) -> AppResult<EncryptedJson<serde_json::Value>> {
|
||||
let reg_info = state.get_registration_info(auth_header.token());
|
||||
|
||||
match reg_info {
|
||||
Some(reg) => Ok(EncryptedJson {
|
||||
key_b16: reg.shared_secret_b16,
|
||||
data: json!(true),
|
||||
}),
|
||||
None => Err(AppError::Unauthorized),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn cancel_request<T>(
|
||||
State((state, _app_handle)): State<(Arc<AppState>, T)>,
|
||||
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
|
||||
Path(req_id): Path<usize>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
if !state.validate_access(auth_header.token()) {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
if let Some((_, token)) = state.remove_cancellation_token(req_id) {
|
||||
token.cancel();
|
||||
Ok(Json(json!({"message": "Request cancelled successfully"})))
|
||||
} else {
|
||||
Err(AppError::RequestNotFound)
|
||||
}
|
||||
}
|
||||
75
packages/hoppscotch-agent/src-tauri/src/error.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AppError {
|
||||
#[error("Invalid Registration")]
|
||||
InvalidRegistration,
|
||||
#[error("Invalid Client Public Key")]
|
||||
InvalidClientPublicKey,
|
||||
#[error("Unauthorized")]
|
||||
Unauthorized,
|
||||
#[error("Request not found or already completed")]
|
||||
RequestNotFound,
|
||||
#[error("Internal server error")]
|
||||
InternalServerError,
|
||||
#[error("Invalid request: {0}")]
|
||||
BadRequest(String),
|
||||
#[error("Client certificate error")]
|
||||
ClientCertError,
|
||||
#[error("Root certificate error")]
|
||||
RootCertError,
|
||||
#[error("Invalid method")]
|
||||
InvalidMethod,
|
||||
#[error("Invalid URL")]
|
||||
InvalidUrl,
|
||||
#[error("Invalid headers")]
|
||||
InvalidHeaders,
|
||||
#[error("Request run error: {0}")]
|
||||
RequestRunError(String),
|
||||
#[error("Request cancelled")]
|
||||
RequestCancelled,
|
||||
#[error("Failed to clear registrations")]
|
||||
RegistrationClearError,
|
||||
#[error("Failed to insert registrations")]
|
||||
RegistrationInsertError,
|
||||
#[error("Failed to save registrations to store")]
|
||||
RegistrationSaveError,
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, error_message) = match self {
|
||||
AppError::InvalidRegistration => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||
AppError::InvalidClientPublicKey => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()),
|
||||
AppError::RequestNotFound => (StatusCode::NOT_FOUND, self.to_string()),
|
||||
AppError::InternalServerError => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
|
||||
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
|
||||
AppError::ClientCertError => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||
AppError::RootCertError => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||
AppError::InvalidMethod => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||
AppError::InvalidUrl => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||
AppError::InvalidHeaders => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||
AppError::RequestRunError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
|
||||
AppError::RequestCancelled => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||
_ => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Internal Server Error".to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
let body = Json(json!({
|
||||
"error": error_message,
|
||||
}));
|
||||
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub type AppResult<T> = std::result::Result<T, AppError>;
|
||||
567
packages/hoppscotch-agent/src-tauri/src/interceptor.rs
Normal file
@@ -0,0 +1,567 @@
|
||||
use crate::{
|
||||
error::AppError,
|
||||
model::{BodyDef, ClientCertDef, FormDataValue, KeyValuePair, RequestDef, RunRequestResponse},
|
||||
util::get_status_text,
|
||||
};
|
||||
use curl::easy::{Easy, List};
|
||||
use openssl::{pkcs12::Pkcs12, ssl::SslContextBuilder, x509::X509};
|
||||
use openssl_sys::SSL_CTX;
|
||||
use std::time::SystemTime;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
pub(crate) fn run_request_task(
|
||||
req: &RequestDef,
|
||||
cancel_token: CancellationToken,
|
||||
) -> Result<RunRequestResponse, AppError> {
|
||||
let mut curl_handle = Easy::new();
|
||||
|
||||
curl_handle
|
||||
.progress(true)
|
||||
.map_err(|err| AppError::RequestRunError(err.description().to_string()))?;
|
||||
|
||||
curl_handle
|
||||
.custom_request(&req.method)
|
||||
.map_err(|_| AppError::InvalidMethod)?;
|
||||
|
||||
curl_handle
|
||||
.url(&req.endpoint)
|
||||
.map_err(|_| AppError::InvalidUrl)?;
|
||||
|
||||
curl_handle
|
||||
.http_headers(get_headers_list(&req)?)
|
||||
.map_err(|_| AppError::InvalidHeaders)?;
|
||||
|
||||
apply_body_to_curl_handle(&mut curl_handle, &req)?;
|
||||
|
||||
curl_handle
|
||||
.ssl_verify_peer(req.validate_certs)
|
||||
.map_err(|err| AppError::RequestRunError(err.description().to_string()))?;
|
||||
|
||||
curl_handle
|
||||
.ssl_verify_host(req.validate_certs)
|
||||
.map_err(|err| AppError::RequestRunError(err.description().to_string()))?;
|
||||
|
||||
apply_client_cert_to_curl_handle(&mut curl_handle, &req)?;
|
||||
|
||||
apply_proxy_config_to_curl_handle(&mut curl_handle, &req)?;
|
||||
|
||||
let mut response_body = Vec::new();
|
||||
let mut response_headers = Vec::new();
|
||||
|
||||
let (start_time_ms, end_time_ms) = {
|
||||
let mut transfer = curl_handle.transfer();
|
||||
|
||||
transfer
|
||||
.ssl_ctx_function(|ssl_ctx_ptr| {
|
||||
let cert_list = get_x509_certs_from_root_cert_bundle(&req);
|
||||
|
||||
if !cert_list.is_empty() {
|
||||
let mut ssl_ctx_builder =
|
||||
unsafe { SslContextBuilder::from_ptr(ssl_ctx_ptr as *mut SSL_CTX) };
|
||||
|
||||
let cert_store = ssl_ctx_builder.cert_store_mut();
|
||||
|
||||
for cert in cert_list {
|
||||
if let Err(e) = cert_store.add_cert(cert) {
|
||||
eprintln!("Failed writing cert into cert store: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|err| AppError::RequestRunError(err.description().to_string()))?;
|
||||
|
||||
transfer
|
||||
.progress_function(|_, _, _, _| !cancel_token.is_cancelled())
|
||||
.map_err(|err| AppError::RequestRunError(err.description().to_string()))?;
|
||||
|
||||
transfer
|
||||
.header_function(|header| {
|
||||
let header = String::from_utf8_lossy(header).into_owned();
|
||||
|
||||
if let Some((key, value)) = header.split_once(':') {
|
||||
response_headers.push(KeyValuePair {
|
||||
key: key.trim().to_string(),
|
||||
value: value.trim().to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
true
|
||||
})
|
||||
.map_err(|err| AppError::RequestRunError(err.description().to_string()))?;
|
||||
|
||||
transfer
|
||||
.write_function(|data| {
|
||||
response_body.extend_from_slice(data);
|
||||
Ok(data.len())
|
||||
})
|
||||
.map_err(|err| AppError::RequestRunError(err.description().to_string()))?;
|
||||
|
||||
let start_time_ms = SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis();
|
||||
|
||||
transfer
|
||||
.perform()
|
||||
.map_err(|err| AppError::RequestRunError(err.description().to_string()))?;
|
||||
|
||||
let end_time_ms = SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis();
|
||||
|
||||
(start_time_ms, end_time_ms)
|
||||
};
|
||||
|
||||
let response_status = curl_handle
|
||||
.response_code()
|
||||
.map_err(|err| AppError::RequestRunError(err.description().to_string()))?
|
||||
as u16;
|
||||
|
||||
let response_status_text = get_status_text(response_status).to_string();
|
||||
|
||||
Ok(RunRequestResponse {
|
||||
status: response_status,
|
||||
status_text: response_status_text,
|
||||
headers: response_headers,
|
||||
data: response_body,
|
||||
time_start_ms: start_time_ms,
|
||||
time_end_ms: end_time_ms,
|
||||
})
|
||||
}
|
||||
|
||||
fn get_headers_list(req: &RequestDef) -> Result<List, AppError> {
|
||||
let mut result = List::new();
|
||||
|
||||
for KeyValuePair { key, value } in &req.headers {
|
||||
result
|
||||
.append(&format!("{}: {}", key, value))
|
||||
.map_err(|err| AppError::RequestRunError(err.description().to_string()))?;
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn apply_body_to_curl_handle(curl_handle: &mut Easy, req: &RequestDef) -> Result<(), AppError> {
|
||||
match &req.body {
|
||||
Some(BodyDef::Text(text)) => {
|
||||
curl_handle
|
||||
.post_fields_copy(text.as_bytes())
|
||||
.map_err(|err| {
|
||||
AppError::RequestRunError(format!(
|
||||
"Error while setting body: {}",
|
||||
err.description()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
Some(BodyDef::FormData(entries)) => {
|
||||
let mut form = curl::easy::Form::new();
|
||||
|
||||
for entry in entries {
|
||||
let mut part = form.part(&entry.key);
|
||||
|
||||
match &entry.value {
|
||||
FormDataValue::Text(data) => {
|
||||
part.contents(data.as_bytes());
|
||||
}
|
||||
FormDataValue::File {
|
||||
filename,
|
||||
data,
|
||||
mime,
|
||||
} => {
|
||||
part.buffer(filename, data.clone()).content_type(mime);
|
||||
}
|
||||
};
|
||||
|
||||
part.add().map_err(|err| {
|
||||
AppError::RequestRunError(format!(
|
||||
"Error while setting body: {}",
|
||||
err.description()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
|
||||
curl_handle.httppost(form).map_err(|err| {
|
||||
AppError::RequestRunError(format!(
|
||||
"Error while setting body: {}",
|
||||
err.description()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
Some(BodyDef::URLEncoded(entries)) => {
|
||||
let data = entries
|
||||
.iter()
|
||||
.map(|KeyValuePair { key, value }| {
|
||||
format!(
|
||||
"{}={}",
|
||||
&url_escape::encode_www_form_urlencoded(key),
|
||||
url_escape::encode_www_form_urlencoded(value)
|
||||
)
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
.join("&");
|
||||
|
||||
curl_handle
|
||||
.post_fields_copy(data.as_bytes())
|
||||
.map_err(|err| {
|
||||
AppError::RequestRunError(format!(
|
||||
"Error while setting body: {}",
|
||||
err.description()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
None => {}
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_client_cert_to_curl_handle(handle: &mut Easy, req: &RequestDef) -> Result<(), AppError> {
|
||||
match &req.client_cert {
|
||||
Some(ClientCertDef::PEMCert {
|
||||
certificate_pem,
|
||||
key_pem,
|
||||
}) => {
|
||||
handle.ssl_cert_type("PEM").map_err(|err| {
|
||||
AppError::RequestRunError(format!(
|
||||
"Failed setting PEM Cert Type: {}",
|
||||
err.description()
|
||||
))
|
||||
})?;
|
||||
|
||||
handle.ssl_cert_blob(certificate_pem).map_err(|err| {
|
||||
AppError::RequestRunError(format!(
|
||||
"Failed setting PEM Cert Blob: {}",
|
||||
err.description()
|
||||
))
|
||||
})?;
|
||||
|
||||
handle.ssl_key_type("PEM").map_err(|err| {
|
||||
AppError::RequestRunError(format!(
|
||||
"Failed setting PEM key type: {}",
|
||||
err.description()
|
||||
))
|
||||
})?;
|
||||
|
||||
handle.ssl_key_blob(key_pem).map_err(|err| {
|
||||
AppError::RequestRunError(format!(
|
||||
"Failed setting PEM Cert blob: {}",
|
||||
err.description()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
Some(ClientCertDef::PFXCert {
|
||||
certificate_pfx,
|
||||
password,
|
||||
}) => {
|
||||
let pkcs12 = Pkcs12::from_der(&certificate_pfx).map_err(|err| {
|
||||
AppError::RequestRunError(format!(
|
||||
"Failed to parse PFX certificate from DER: {}",
|
||||
err
|
||||
))
|
||||
})?;
|
||||
|
||||
let parsed = pkcs12.parse2(password).map_err(|err| {
|
||||
AppError::RequestRunError(format!(
|
||||
"Failed to parse PFX certificate with provided password: {}",
|
||||
err
|
||||
))
|
||||
})?;
|
||||
|
||||
if let (Some(cert), Some(key)) = (parsed.cert, parsed.pkey) {
|
||||
let certificate_pem = cert.to_pem().map_err(|err| {
|
||||
AppError::RequestRunError(format!(
|
||||
"Failed to convert PFX certificate to PEM format: {}",
|
||||
err
|
||||
))
|
||||
})?;
|
||||
|
||||
let key_pem = key.private_key_to_pem_pkcs8().map_err(|err| {
|
||||
AppError::RequestRunError(format!(
|
||||
"Failed to convert PFX private key to PEM format: {}",
|
||||
err
|
||||
))
|
||||
})?;
|
||||
|
||||
handle.ssl_cert_type("PEM").map_err(|err| {
|
||||
AppError::RequestRunError(format!(
|
||||
"Failed setting PEM Cert Type for converted PFX: {}",
|
||||
err.description()
|
||||
))
|
||||
})?;
|
||||
|
||||
handle.ssl_cert_blob(&certificate_pem).map_err(|err| {
|
||||
AppError::RequestRunError(format!(
|
||||
"Failed setting PEM Cert Blob for converted PFX: {}",
|
||||
err.description()
|
||||
))
|
||||
})?;
|
||||
|
||||
handle.ssl_key_type("PEM").map_err(|err| {
|
||||
AppError::RequestRunError(format!(
|
||||
"Failed setting PEM key type for converted PFX: {}",
|
||||
err.description()
|
||||
))
|
||||
})?;
|
||||
|
||||
handle.ssl_key_blob(&key_pem).map_err(|err| {
|
||||
AppError::RequestRunError(format!(
|
||||
"Failed setting PEM key blob for converted PFX: {}",
|
||||
err.description()
|
||||
))
|
||||
})?;
|
||||
} else {
|
||||
return Err(AppError::RequestRunError(
|
||||
"PFX certificate parsing succeeded, but either cert or private key is missing"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_x509_certs_from_root_cert_bundle(req: &RequestDef) -> Vec<X509> {
|
||||
req.root_cert_bundle_files
|
||||
.iter()
|
||||
.map(|pem_bundle| openssl::x509::X509::stack_from_pem(pem_bundle))
|
||||
.filter_map(|certs| {
|
||||
if let Ok(certs) = certs {
|
||||
Some(certs)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn apply_proxy_config_to_curl_handle(handle: &mut Easy, req: &RequestDef) -> Result<(), AppError> {
|
||||
if let Some(proxy_config) = &req.proxy {
|
||||
handle
|
||||
.proxy_auth(curl::easy::Auth::new().auto(true))
|
||||
.map_err(|err| {
|
||||
AppError::RequestRunError(format!(
|
||||
"Failed to set proxy Auth Mode: {}",
|
||||
err.description()
|
||||
))
|
||||
})?;
|
||||
|
||||
handle.proxy(&proxy_config.url).map_err(|err| {
|
||||
AppError::RequestRunError(format!("Failed to set proxy URL: {}", err.description()))
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::model::FormDataEntry;
|
||||
|
||||
use super::*;
|
||||
use mockito::Server;
|
||||
|
||||
#[test]
|
||||
fn test_run_request_task_success() {
|
||||
let mut server = Server::new();
|
||||
let mock = server
|
||||
.mock("GET", "/test")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "text/plain")
|
||||
.with_body("Hello, World!")
|
||||
.create();
|
||||
|
||||
let req = RequestDef {
|
||||
req_id: 1,
|
||||
method: "GET".to_string(),
|
||||
endpoint: format!("{}/test", server.url()),
|
||||
headers: vec![],
|
||||
body: None,
|
||||
validate_certs: false,
|
||||
root_cert_bundle_files: vec![],
|
||||
client_cert: None,
|
||||
proxy: None,
|
||||
};
|
||||
let cancel_token = CancellationToken::new();
|
||||
|
||||
let result = run_request_task(&req, cancel_token);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let response = result.unwrap();
|
||||
assert_eq!(response.status, 200);
|
||||
assert_eq!(response.status_text, "OK");
|
||||
assert!(response
|
||||
.headers
|
||||
.iter()
|
||||
.any(|h| h.key == "content-type" && h.value == "text/plain"));
|
||||
assert_eq!(response.data, b"Hello, World!");
|
||||
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_request_task_with_headers() {
|
||||
let mut server = Server::new();
|
||||
let mock = server
|
||||
.mock("GET", "/test")
|
||||
.match_header("X-Custom-Header", "TestValue")
|
||||
.with_status(200)
|
||||
.create();
|
||||
|
||||
let req = RequestDef {
|
||||
req_id: 1,
|
||||
method: "GET".to_string(),
|
||||
endpoint: format!("{}/test", server.url()),
|
||||
headers: vec![KeyValuePair {
|
||||
key: "X-Custom-Header".to_string(),
|
||||
value: "TestValue".to_string(),
|
||||
}],
|
||||
body: None,
|
||||
validate_certs: false,
|
||||
root_cert_bundle_files: vec![],
|
||||
client_cert: None,
|
||||
proxy: None,
|
||||
};
|
||||
let cancel_token = CancellationToken::new();
|
||||
|
||||
let result = run_request_task(&req, cancel_token);
|
||||
assert!(result.is_ok());
|
||||
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_request_task_with_body() {
|
||||
let mut server = Server::new();
|
||||
let mock = server
|
||||
.mock("POST", "/test")
|
||||
.match_body("test_body")
|
||||
.with_status(201)
|
||||
.create();
|
||||
|
||||
let req = RequestDef {
|
||||
req_id: 1,
|
||||
method: "POST".to_string(),
|
||||
endpoint: format!("{}/test", server.url()),
|
||||
headers: vec![],
|
||||
body: Some(BodyDef::Text("test_body".to_string())),
|
||||
validate_certs: false,
|
||||
root_cert_bundle_files: vec![],
|
||||
client_cert: None,
|
||||
proxy: None,
|
||||
};
|
||||
let cancel_token = CancellationToken::new();
|
||||
|
||||
let result = run_request_task(&req, cancel_token);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap().status, 201);
|
||||
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_request_task_with_url_encoded_body() {
|
||||
let mut server = Server::new();
|
||||
let mock = server
|
||||
.mock("POST", "/test")
|
||||
.match_body("key1=value1&key2=value2")
|
||||
.with_status(200)
|
||||
.create();
|
||||
|
||||
let req = RequestDef {
|
||||
req_id: 1,
|
||||
method: "POST".to_string(),
|
||||
endpoint: format!("{}/test", server.url()),
|
||||
headers: vec![],
|
||||
body: Some(BodyDef::URLEncoded(vec![
|
||||
KeyValuePair {
|
||||
key: "key1".to_string(),
|
||||
value: "value1".to_string(),
|
||||
},
|
||||
KeyValuePair {
|
||||
key: "key2".to_string(),
|
||||
value: "value2".to_string(),
|
||||
},
|
||||
])),
|
||||
validate_certs: false,
|
||||
root_cert_bundle_files: vec![],
|
||||
client_cert: None,
|
||||
proxy: None,
|
||||
};
|
||||
let cancel_token = CancellationToken::new();
|
||||
|
||||
let result = run_request_task(&req, cancel_token);
|
||||
assert!(result.is_ok());
|
||||
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_request_task_with_invalid_url() {
|
||||
let req = RequestDef {
|
||||
req_id: 1,
|
||||
method: "GET".to_string(),
|
||||
endpoint: "invalid_url".to_string(),
|
||||
headers: vec![],
|
||||
body: None,
|
||||
validate_certs: false,
|
||||
root_cert_bundle_files: vec![],
|
||||
client_cert: None,
|
||||
proxy: None,
|
||||
};
|
||||
let cancel_token = CancellationToken::new();
|
||||
|
||||
let result = run_request_task(&req, cancel_token);
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_request_task_with_form_data() {
|
||||
let mut server = Server::new();
|
||||
let mock = server
|
||||
.mock("POST", "/test")
|
||||
.match_header(
|
||||
"content-type",
|
||||
mockito::Matcher::Regex("multipart/form-data.*".to_string()),
|
||||
)
|
||||
.with_status(200)
|
||||
.create();
|
||||
|
||||
let req = RequestDef {
|
||||
req_id: 1,
|
||||
method: "POST".to_string(),
|
||||
endpoint: format!("{}/test", server.url()),
|
||||
headers: vec![],
|
||||
body: Some(BodyDef::FormData(vec![
|
||||
FormDataEntry {
|
||||
key: "text_field".to_string(),
|
||||
value: FormDataValue::Text("text_value".to_string()),
|
||||
},
|
||||
FormDataEntry {
|
||||
key: "file_field".to_string(),
|
||||
value: FormDataValue::File {
|
||||
filename: "test.txt".to_string(),
|
||||
data: b"file_content".to_vec(),
|
||||
mime: "text/plain".to_string(),
|
||||
},
|
||||
},
|
||||
])),
|
||||
validate_certs: false,
|
||||
root_cert_bundle_files: vec![],
|
||||
client_cert: None,
|
||||
proxy: None,
|
||||
};
|
||||
let cancel_token = CancellationToken::new();
|
||||
|
||||
let result = run_request_task(&req, cancel_token);
|
||||
assert!(result.is_ok());
|
||||
|
||||
mock.assert();
|
||||
}
|
||||
}
|
||||
162
packages/hoppscotch-agent/src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,162 @@
|
||||
pub mod controller;
|
||||
pub mod error;
|
||||
pub mod interceptor;
|
||||
pub mod model;
|
||||
pub mod route;
|
||||
pub mod server;
|
||||
pub mod state;
|
||||
pub mod tray;
|
||||
pub mod updater;
|
||||
pub mod util;
|
||||
|
||||
use state::AppState;
|
||||
use std::sync::Arc;
|
||||
use tauri::{Listener, Manager, Url, WebviewWindowBuilder};
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_otp(state: tauri::State<'_, Arc<AppState>>) -> Result<Option<String>, ()> {
|
||||
Ok(state.active_registration_code.read().await.clone())
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
env_logger::init();
|
||||
|
||||
let cancellation_token = CancellationToken::new();
|
||||
let server_cancellation_token = cancellation_token.clone();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.setup(move |app| {
|
||||
let app_handle = app.app_handle();
|
||||
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
use tauri_plugin_autostart::MacosLauncher;
|
||||
use tauri_plugin_autostart::ManagerExt;
|
||||
|
||||
let _ = app.handle().plugin(tauri_plugin_autostart::init(
|
||||
MacosLauncher::LaunchAgent,
|
||||
None
|
||||
));
|
||||
|
||||
let autostart_manager = app.autolaunch();
|
||||
|
||||
println!("autostart enabled: {}", autostart_manager.is_enabled().unwrap());
|
||||
|
||||
if !autostart_manager.is_enabled().unwrap() {
|
||||
let _ = autostart_manager.enable();
|
||||
println!("autostart updated: {}", autostart_manager.is_enabled().unwrap());
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
// We use env variables to define the pubkey for installer to check
|
||||
let updater_pub_key = option_env!("UPDATER_PUB_KEY");
|
||||
let updater_url = option_env!("UPDATER_URL");
|
||||
|
||||
if let (Some(pub_key), Some(updater_url)) = (updater_pub_key, updater_url) {
|
||||
let _ = app.handle()
|
||||
.plugin(tauri_plugin_updater::Builder::new() .build());
|
||||
|
||||
let _ = app.handle()
|
||||
.plugin(tauri_plugin_dialog::init());
|
||||
|
||||
let updater_url: Url = updater_url.parse().unwrap();
|
||||
|
||||
let updater = app.updater_builder()
|
||||
.pubkey(pub_key)
|
||||
.endpoints(
|
||||
vec![updater_url]
|
||||
)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let app_handle_ref = app_handle.clone();
|
||||
|
||||
tauri::async_runtime::spawn_blocking(|| {
|
||||
tauri::async_runtime::block_on(async {
|
||||
updater::check_and_install_updates(app_handle_ref, updater).await;
|
||||
})
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let app_state = Arc::new(AppState::new(app_handle.clone()));
|
||||
|
||||
app.manage(app_state.clone());
|
||||
|
||||
let server_cancellation_token = server_cancellation_token.clone();
|
||||
|
||||
let server_app_handle = app_handle.clone();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
server::run_server(app_state, server_cancellation_token, server_app_handle)
|
||||
.await;
|
||||
});
|
||||
|
||||
#[cfg(all(desktop))]
|
||||
{
|
||||
let handle = app.handle();
|
||||
tray::create_tray(handle)?;
|
||||
}
|
||||
|
||||
// Blocks the app from populating the macOS dock
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
app_handle.set_activation_policy(tauri::ActivationPolicy::Accessory)
|
||||
.unwrap();
|
||||
};
|
||||
|
||||
let app_handle_ref = app_handle.clone();
|
||||
|
||||
app_handle.listen("registration_received", move |_| {
|
||||
WebviewWindowBuilder::from_config(
|
||||
&app_handle_ref,
|
||||
&app_handle_ref.config().app.windows[0]
|
||||
)
|
||||
.unwrap()
|
||||
.build()
|
||||
.unwrap()
|
||||
.show()
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.manage(cancellation_token)
|
||||
.on_window_event(|window, event| {
|
||||
match &event {
|
||||
tauri::WindowEvent::CloseRequested { .. } => {
|
||||
let app_state = window.state::<Arc<AppState>>();
|
||||
|
||||
let mut current_code =
|
||||
app_state.active_registration_code.blocking_write();
|
||||
|
||||
if current_code.is_some() {
|
||||
*current_code = None;
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
};
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_otp
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app_handle, event| match event {
|
||||
tauri::RunEvent::ExitRequested { api, code, .. } => {
|
||||
if code.is_none() || matches!(code, Some(0)) {
|
||||
api.prevent_exit()
|
||||
} else if code.is_some() {
|
||||
let state = app_handle.state::<CancellationToken>();
|
||||
state.cancel();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
}
|
||||
6
packages/hoppscotch-agent/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
hoppscotch_agent_lib::run()
|
||||
}
|
||||
103
packages/hoppscotch-agent/src-tauri/src/model.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct HandshakeResponse {
|
||||
#[allow(non_snake_case)]
|
||||
pub __hoppscotch__agent__: bool,
|
||||
|
||||
pub status: String,
|
||||
pub agent_version: String
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ConfirmedRegistrationRequest {
|
||||
pub registration: String,
|
||||
|
||||
/// base16 (lowercase) encoded public key shared by the client
|
||||
/// to the agent so that the agent can establish a shared secret
|
||||
/// which will be used to encrypt traffic between agent
|
||||
/// and client after registration
|
||||
pub client_public_key_b16: String
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AuthKeyResponse {
|
||||
pub auth_key: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
|
||||
/// base16 (lowercase) encoded public key shared by the
|
||||
/// agent so that the client can establish a shared secret
|
||||
/// which will be used to encrypt traffic between agent
|
||||
/// and client after registration
|
||||
pub agent_public_key_b16: String
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct KeyValuePair {
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub enum FormDataValue {
|
||||
Text(String),
|
||||
File {
|
||||
filename: String,
|
||||
data: Vec<u8>,
|
||||
mime: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct FormDataEntry {
|
||||
pub key: String,
|
||||
pub value: FormDataValue,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub enum BodyDef {
|
||||
Text(String),
|
||||
URLEncoded(Vec<KeyValuePair>),
|
||||
FormData(Vec<FormDataEntry>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RequestDef {
|
||||
pub req_id: usize,
|
||||
pub method: String,
|
||||
pub endpoint: String,
|
||||
pub headers: Vec<KeyValuePair>,
|
||||
pub body: Option<BodyDef>,
|
||||
pub validate_certs: bool,
|
||||
pub root_cert_bundle_files: Vec<Vec<u8>>,
|
||||
pub client_cert: Option<ClientCertDef>,
|
||||
pub proxy: Option<ProxyConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ProxyConfig {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub enum ClientCertDef {
|
||||
PEMCert {
|
||||
certificate_pem: Vec<u8>,
|
||||
key_pem: Vec<u8>,
|
||||
},
|
||||
PFXCert {
|
||||
certificate_pfx: Vec<u8>,
|
||||
password: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RunRequestResponse {
|
||||
pub status: u16,
|
||||
pub status_text: String,
|
||||
pub headers: Vec<KeyValuePair>,
|
||||
pub data: Vec<u8>,
|
||||
pub time_start_ms: u128,
|
||||
pub time_end_ms: u128,
|
||||
}
|
||||
28
packages/hoppscotch-agent/src-tauri/src/route.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use axum::{
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::{controller, state::AppState};
|
||||
|
||||
pub fn route(state: Arc<AppState>, app_handle: AppHandle) -> Router {
|
||||
Router::new()
|
||||
.route("/handshake", get(controller::handshake))
|
||||
.route(
|
||||
"/receive-registration",
|
||||
post(controller::receive_registration),
|
||||
)
|
||||
.route(
|
||||
"/verify-registration",
|
||||
post(controller::verify_registration),
|
||||
)
|
||||
.route(
|
||||
"/registered-handshake",
|
||||
get(controller::registered_handshake),
|
||||
)
|
||||
.route("/request", post(controller::run_request))
|
||||
.route("/cancel-request/:req_id", post(controller::cancel_request))
|
||||
.with_state((state, app_handle))
|
||||
}
|
||||
34
packages/hoppscotch-agent/src-tauri/src/server.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use axum::Router;
|
||||
use std::sync::Arc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tower_http::cors::CorsLayer;
|
||||
|
||||
use crate::route;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub async fn run_server(
|
||||
state: Arc<AppState>,
|
||||
cancellation_token: CancellationToken,
|
||||
app_handle: tauri::AppHandle,
|
||||
) {
|
||||
let cors = CorsLayer::permissive();
|
||||
|
||||
let app = Router::new()
|
||||
.merge(route::route(state, app_handle))
|
||||
.layer(cors);
|
||||
|
||||
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 9119));
|
||||
|
||||
println!("Server running on http://{}", addr);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
||||
|
||||
axum::serve(listener, app.into_make_service())
|
||||
.with_graceful_shutdown(async move {
|
||||
cancellation_token.cancelled().await;
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
println!("Server shut down");
|
||||
}
|
||||
154
packages/hoppscotch-agent/src-tauri/src/state.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
use aes_gcm::{aead::Aead, Aes256Gcm, KeyInit};
|
||||
use axum::body::Bytes;
|
||||
use chrono::{DateTime, Utc};
|
||||
use dashmap::DashMap;
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use tauri_plugin_store::StoreBuilder;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// Describes one registered app instance
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Registration {
|
||||
pub registered_at: DateTime<Utc>,
|
||||
|
||||
/// base16 (lowercase) encoded shared secret that the client
|
||||
/// and agent established during registration that is used
|
||||
/// to encrypt traffic between them
|
||||
pub shared_secret_b16: String
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct AppState {
|
||||
/// The active registration code that is being registered.
|
||||
pub active_registration_code: RwLock<Option<String>>,
|
||||
|
||||
/// Cancellation Tokens for the running requests
|
||||
pub cancellation_tokens: DashMap<usize, CancellationToken>,
|
||||
|
||||
/// Registrations against the agent, the key is the auth
|
||||
/// token associated to the registration
|
||||
registrations: DashMap<String, Registration>
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
|
||||
pub fn new(app_handle: tauri::AppHandle) -> Self {
|
||||
let mut store = StoreBuilder::new("app_data.bin")
|
||||
.build(app_handle);
|
||||
|
||||
let _ = store.load();
|
||||
|
||||
// Try loading and parsing registrations from the store, if that failed,
|
||||
// load the default list
|
||||
let registrations = store.get("registrations")
|
||||
.and_then(|val| serde_json::from_value(val.clone()).ok())
|
||||
.unwrap_or_else(|| DashMap::new());
|
||||
|
||||
|
||||
Self {
|
||||
active_registration_code: RwLock::new(None),
|
||||
cancellation_tokens: DashMap::new(),
|
||||
registrations
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets you a readonly reference to the registrations list
|
||||
/// NOTE: Although DashMap API allows you to update the list from an immutable
|
||||
/// reference, you shouldn't do it for registrations as `update_registrations`
|
||||
/// performs save operation that needs to be done and should be used instead
|
||||
pub fn get_registrations(&self) -> &DashMap<String, Registration> {
|
||||
&self.registrations
|
||||
}
|
||||
|
||||
/// Provides you an opportunity to update the registrations list
|
||||
/// and also persists the data to the disk
|
||||
pub fn update_registrations(
|
||||
&self,
|
||||
app_handle: tauri::AppHandle,
|
||||
update_func: impl FnOnce(&DashMap<String, Registration>)
|
||||
) -> Result<(), AppError> {
|
||||
update_func(&self.registrations);
|
||||
|
||||
let mut store = StoreBuilder::new("app_data.bin")
|
||||
.build(app_handle);
|
||||
|
||||
let _ = store.load();
|
||||
|
||||
store.delete("registrations")
|
||||
.map_err(|_| AppError::RegistrationClearError)?;
|
||||
|
||||
store.insert("registrations".into(), serde_json::to_value(self.registrations.clone()).unwrap())
|
||||
.map_err(|_| AppError::RegistrationInsertError)?;
|
||||
|
||||
store.save()
|
||||
.map_err(|_| AppError::RegistrationSaveError)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn validate_registration(&self, registration: &str) -> bool {
|
||||
match *self.active_registration_code.read().await {
|
||||
Some(ref code) => code == registration,
|
||||
None => false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_cancellation_token(&self, req_id: usize) -> Option<(usize, CancellationToken)> {
|
||||
self.cancellation_tokens.remove(&req_id)
|
||||
}
|
||||
|
||||
pub fn add_cancellation_token(&self, req_id: usize, cancellation_tokens: CancellationToken) {
|
||||
self.cancellation_tokens.insert(req_id, cancellation_tokens);
|
||||
}
|
||||
|
||||
pub fn validate_access(&self, auth_key: &str) -> bool {
|
||||
self.registrations.get(auth_key).is_some()
|
||||
}
|
||||
|
||||
pub fn validate_access_and_get_data<T>(
|
||||
&self,
|
||||
auth_key: &str,
|
||||
nonce: &str,
|
||||
data: &Bytes
|
||||
) -> Option<T>
|
||||
where
|
||||
T : DeserializeOwned
|
||||
{
|
||||
if let Some(registration) = self.registrations.get(auth_key) {
|
||||
let key: [u8; 32] = base16::decode(®istration.shared_secret_b16)
|
||||
.ok()?
|
||||
[0..32]
|
||||
.try_into()
|
||||
.ok()?;
|
||||
|
||||
let nonce: [u8; 12] = base16::decode(nonce)
|
||||
.ok()?
|
||||
[0..12]
|
||||
.try_into()
|
||||
.ok()?;
|
||||
|
||||
let cipher = Aes256Gcm::new(&key.into());
|
||||
|
||||
let data = data
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<Vec<u8>>();
|
||||
|
||||
let plain_data = cipher.decrypt(&nonce.into(), data.as_slice())
|
||||
.ok()?;
|
||||
|
||||
serde_json::from_reader(plain_data.as_slice())
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_registration_info(&self, auth_key: &str) -> Option<Registration> {
|
||||
self.registrations.get(auth_key)
|
||||
.map(|reference| reference.value().clone())
|
||||
}
|
||||
}
|
||||
90
packages/hoppscotch-agent/src-tauri/src/tray.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
use crate::state::AppState;
|
||||
use lazy_static::lazy_static;
|
||||
use std::sync::Arc;
|
||||
use tauri::{
|
||||
image::Image,
|
||||
menu::{MenuBuilder, MenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
AppHandle, Manager,
|
||||
};
|
||||
|
||||
const TRAY_ICON_DATA: &'static [u8] = include_bytes!("../icons/tray_icon.png");
|
||||
|
||||
lazy_static! {
|
||||
static ref TRAY_ICON: Image<'static> = Image::from_bytes(TRAY_ICON_DATA).unwrap();
|
||||
}
|
||||
|
||||
pub fn create_tray(app: &AppHandle) -> tauri::Result<()> {
|
||||
let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
|
||||
let clear_registrations = MenuItem::with_id(
|
||||
app,
|
||||
"clear_registrations",
|
||||
"Clear Registrations",
|
||||
true,
|
||||
None::<&str>,
|
||||
)?;
|
||||
|
||||
let pkg_info = app.package_info();
|
||||
let app_name = pkg_info.name.clone();
|
||||
let app_version = pkg_info.version.clone();
|
||||
|
||||
let app_name_item = MenuItem::with_id(app, "app_name", app_name, false, None::<&str>)?;
|
||||
let app_version_item = MenuItem::with_id(
|
||||
app,
|
||||
"app_version",
|
||||
format!("Version: {}", app_version),
|
||||
false,
|
||||
None::<&str>,
|
||||
)?;
|
||||
|
||||
let menu = MenuBuilder::new(app)
|
||||
.item(&app_name_item)
|
||||
.item(&app_version_item)
|
||||
.separator()
|
||||
.item(&clear_registrations)
|
||||
.item(&quit_i)
|
||||
.build()?;
|
||||
|
||||
let _ = TrayIconBuilder::with_id("hopp-tray")
|
||||
.tooltip("Hoppscotch Agent")
|
||||
.icon(if cfg!(target_os = "macos") {
|
||||
TRAY_ICON.clone()
|
||||
} else {
|
||||
app.default_window_icon().unwrap().clone()
|
||||
})
|
||||
.icon_as_template(cfg!(target_os = "macos"))
|
||||
.menu(&menu)
|
||||
.menu_on_left_click(true)
|
||||
.on_menu_event(move |app, event| match event.id.as_ref() {
|
||||
"quit" => {
|
||||
app.exit(-1);
|
||||
}
|
||||
"clear_registrations" => {
|
||||
let app_state = app.state::<Arc<AppState>>();
|
||||
|
||||
app_state
|
||||
.update_registrations(app.clone(), |regs| {
|
||||
regs.clear();
|
||||
})
|
||||
.expect("Failed to clear registrations");
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
let app = tray.app_handle();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(app);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
34
packages/hoppscotch-agent/src-tauri/src/updater.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
#[cfg(desktop)]
|
||||
pub async fn check_and_install_updates(app: tauri::AppHandle, updater: tauri_plugin_updater::Updater) {
|
||||
use tauri::Manager;
|
||||
use tauri_plugin_dialog::MessageDialogKind;
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
|
||||
let update = updater.check().await;
|
||||
|
||||
if let Ok(Some(update)) = update {
|
||||
let do_update = app.dialog()
|
||||
.message(
|
||||
format!(
|
||||
"Update to {} is available!{}",
|
||||
update.version,
|
||||
update.body
|
||||
.clone()
|
||||
.map(|body| format!("\n\nRelease Notes: {}", body))
|
||||
.unwrap_or("".into())
|
||||
)
|
||||
)
|
||||
.title("Update Available")
|
||||
.kind(MessageDialogKind::Info)
|
||||
.ok_button_label("Update")
|
||||
.cancel_button_label("Cancel")
|
||||
.blocking_show();
|
||||
|
||||
if do_update {
|
||||
let _ = update.download_and_install(|_, _| {}, || {}).await;
|
||||
|
||||
tauri::process::restart(&app.env());
|
||||
}
|
||||
}
|
||||
}
|
||||
47
packages/hoppscotch-agent/src-tauri/src/util.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use aes_gcm::{aead::Aead, AeadCore, Aes256Gcm, KeyInit};
|
||||
use axum::{body::Body, response::{IntoResponse, Response}};
|
||||
use rand::rngs::OsRng;
|
||||
use serde::Serialize;
|
||||
|
||||
pub fn get_status_text(status: u16) -> &'static str {
|
||||
http::StatusCode::from_u16(status)
|
||||
.map(|status| status.canonical_reason())
|
||||
.unwrap_or(Some("Unknown Status"))
|
||||
.unwrap_or("Unknown Status")
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct EncryptedJson<T: Serialize> {
|
||||
pub key_b16: String,
|
||||
pub data: T
|
||||
}
|
||||
|
||||
impl<T> IntoResponse for EncryptedJson<T> where T: Serialize {
|
||||
fn into_response(self) -> Response {
|
||||
let serialized_response = serde_json::to_vec(&self.data)
|
||||
.expect("Failed serializing response to vec for encryption");
|
||||
|
||||
let key: [u8; 32] = base16::decode(&self.key_b16)
|
||||
.unwrap()
|
||||
[0..32]
|
||||
.try_into()
|
||||
.unwrap();
|
||||
|
||||
let cipher = Aes256Gcm::new(&key.into());
|
||||
|
||||
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
|
||||
|
||||
let nonce_b16 = base16::encode_lower(&nonce);
|
||||
|
||||
let encrypted_response = cipher.encrypt(&nonce, serialized_response.as_slice())
|
||||
.expect("Failed encrypting response");
|
||||
|
||||
let mut response = Response::new(Body::from(encrypted_response));
|
||||
let response_headers = response.headers_mut();
|
||||
|
||||
response_headers.insert("Content-Type", "application/octet-stream".parse().unwrap());
|
||||
response_headers.insert("X-Hopp-Nonce", nonce_b16.parse().unwrap());
|
||||
|
||||
response
|
||||
}
|
||||
}
|
||||
42
packages/hoppscotch-agent/src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
|
||||
"productName": "Hoppscotch Agent",
|
||||
"version": "0.1.0",
|
||||
"identifier": "io.hoppscotch.agent",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Hoppscotch Agent",
|
||||
"width": 600,
|
||||
"height": 400,
|
||||
"center": true,
|
||||
"resizable": false,
|
||||
"maximizable": false,
|
||||
"minimizable": false,
|
||||
"focus": true,
|
||||
"alwaysOnTop": true,
|
||||
"create": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||