Support client certificates (#319)

This commit is contained in:
Gregory Schier
2025-12-10 13:54:22 -08:00
committed by GitHub
parent ef1ba9b834
commit c4b559f34b
39 changed files with 1131 additions and 236 deletions

1
.gitignore vendored
View File

@@ -25,6 +25,7 @@ dist-ssr
*.sln
*.sw?
.eslintcache
out
*.sqlite
*.sqlite-*

98
src-tauri/Cargo.lock generated
View File

@@ -473,6 +473,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "block2"
version = "0.5.1"
@@ -700,6 +709,15 @@ dependencies = [
"toml 0.8.23",
]
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
]
[[package]]
name = "cc"
version = "1.2.26"
@@ -1230,6 +1248,15 @@ dependencies = [
"syn 2.0.101",
]
[[package]]
name = "des"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e"
dependencies = [
"cipher",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -2623,6 +2650,7 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
"generic-array",
]
@@ -3739,6 +3767,23 @@ dependencies = [
"thiserror 2.0.17",
]
[[package]]
name = "p12"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4873306de53fe82e7e484df31e1e947d61514b6ea2ed6cd7b45d63006fd9224"
dependencies = [
"cbc",
"cipher",
"des",
"getrandom 0.2.16",
"hmac",
"lazy_static",
"rc2",
"sha1",
"yasna",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -4489,6 +4534,15 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rc2"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62c64daa8e9438b84aaae55010a93f396f8e60e3911590fcba770d04643fc1dd"
dependencies = [
"cipher",
]
[[package]]
name = "redox_syscall"
version = "0.5.12"
@@ -4787,6 +4841,15 @@ dependencies = [
"security-framework 3.5.1",
]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.12.0"
@@ -7114,9 +7177,9 @@ dependencies = [
[[package]]
name = "webpki-root-certs"
version = "1.0.0"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01a83f7e1a9f8712695c03eabe9ed3fbca0feff0152f33f12593e5a6303cb1a4"
checksum = "ee3e3b5f5e80bc89f30ce8d0343bf4e5f12341c51f3e26cbeecbc7c85443e85b"
dependencies = [
"rustls-pki-types",
]
@@ -7855,6 +7918,7 @@ dependencies = [
"yaak-sse",
"yaak-sync",
"yaak-templates",
"yaak-tls",
"yaak-ws",
]
@@ -7933,12 +7997,13 @@ dependencies = [
"serde_json",
"tauri",
"tauri-plugin-shell",
"thiserror 2.0.17",
"tokio",
"tokio-stream",
"tonic",
"tonic-reflection",
"uuid",
"yaak-http",
"yaak-tls",
]
[[package]]
@@ -7950,8 +8015,6 @@ dependencies = [
"regex",
"reqwest",
"reqwest_cookie_store",
"rustls",
"rustls-platform-verifier",
"serde",
"tauri",
"thiserror 2.0.17",
@@ -7959,6 +8022,7 @@ dependencies = [
"tower-service",
"urlencoding",
"yaak-models",
"yaak-tls",
]
[[package]]
@@ -8093,13 +8157,28 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "yaak-tls"
version = "0.1.0"
dependencies = [
"log",
"p12",
"rustls",
"rustls-pemfile",
"rustls-platform-verifier",
"serde",
"thiserror 2.0.17",
"url",
"yaak-models",
]
[[package]]
name = "yaak-ws"
version = "0.1.0"
dependencies = [
"futures-util",
"log",
"md5 0.7.0",
"md5 0.8.0",
"reqwest_cookie_store",
"serde",
"serde_json",
@@ -8112,8 +8191,15 @@ dependencies = [
"yaak-models",
"yaak-plugins",
"yaak-templates",
"yaak-tls",
]
[[package]]
name = "yasna"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
[[package]]
name = "yoke"
version = "0.8.0"

View File

@@ -12,6 +12,7 @@ members = [
"yaak-sse",
"yaak-sync",
"yaak-templates",
"yaak-tls",
"yaak-ws",
]
@@ -86,6 +87,7 @@ yaak-plugins = { workspace = true }
yaak-sse = { workspace = true }
yaak-sync = { workspace = true }
yaak-templates = { workspace = true }
yaak-tls = { workspace = true }
yaak-ws = { path = "yaak-ws" }
[workspace.dependencies]
@@ -116,3 +118,4 @@ yaak-plugins = { path = "yaak-plugins" }
yaak-sse = { path = "yaak-sse" }
yaak-sync = { path = "yaak-sync" }
yaak-templates = { path = "yaak-templates" }
yaak-tls = { path = "yaak-tls" }

View File

@@ -37,6 +37,7 @@ use yaak_plugins::events::{
use yaak_plugins::manager::PluginManager;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_templates::{RenderErrorBehavior, RenderOptions};
use yaak_tls::find_client_certificate;
pub async fn send_http_request<R: Runtime>(
window: &WebviewWindow<R>,
@@ -151,6 +152,8 @@ pub async fn send_http_request_with_context<R: Runtime>(
}
};
let client_certificate = find_client_certificate(&url_string, &settings.client_certificates);
// Add cookie store if specified
let maybe_cookie_manager = match cookie_jar.clone() {
Some(CookieJar { id, .. }) => {
@@ -178,22 +181,19 @@ pub async fn send_http_request_with_context<R: Runtime>(
};
let client = connection_manager
.get_client(
&plugin_context.id,
&HttpConnectionOptions {
follow_redirects: workspace.setting_follow_redirects,
validate_certificates: workspace.setting_validate_certificates,
proxy: proxy_setting,
cookie_provider: maybe_cookie_manager.as_ref().map(|(p, _)| Arc::clone(&p)),
timeout: if workspace.setting_request_timeout > 0 {
Some(Duration::from_millis(
workspace.setting_request_timeout.unsigned_abs() as u64
))
} else {
None
},
.get_client(&HttpConnectionOptions {
id: plugin_context.id.clone(),
follow_redirects: workspace.setting_follow_redirects,
validate_certificates: workspace.setting_validate_certificates,
proxy: proxy_setting,
cookie_provider: maybe_cookie_manager.as_ref().map(|(p, _)| Arc::clone(&p)),
client_certificate,
timeout: if workspace.setting_request_timeout > 0 {
Some(Duration::from_millis(workspace.setting_request_timeout.unsigned_abs() as u64))
} else {
None
},
)
})
.await?;
// Render query parameters

View File

@@ -53,6 +53,7 @@ use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_sse::sse::ServerSentEvent;
use yaak_templates::format_json::format_json;
use yaak_templates::{RenderErrorBehavior, RenderOptions, Tokens, transform_args};
use yaak_tls::find_client_certificate;
mod commands;
mod encoding;
@@ -187,6 +188,9 @@ async fn cmd_grpc_reflect<R: Runtime>(
let uri = safe_uri(&req.url);
let metadata = build_metadata(&window, &req, &auth_context_id).await?;
let settings = window.db().get_settings();
let client_certificate =
find_client_certificate(req.url.as_str(), &settings.client_certificates);
Ok(grpc_handle
.lock()
@@ -197,6 +201,7 @@ async fn cmd_grpc_reflect<R: Runtime>(
&proto_files.iter().map(|p| PathBuf::from_str(p).unwrap()).collect(),
&metadata,
workspace.setting_validate_certificates,
client_certificate,
skip_cache.unwrap_or(false),
)
.await
@@ -237,6 +242,10 @@ async fn cmd_grpc_go<R: Runtime>(
let metadata = build_metadata(&window, &request, &auth_context_id).await?;
// Find matching client certificate for this URL
let settings = app_handle.db().get_settings();
let client_cert = find_client_certificate(&request.url, &settings.client_certificates);
let conn = app_handle.db().upsert_grpc_connection(
&GrpcConnection {
workspace_id: request.workspace_id.clone(),
@@ -285,6 +294,7 @@ async fn cmd_grpc_go<R: Runtime>(
&proto_files.iter().map(|p| PathBuf::from_str(p).unwrap()).collect(),
&metadata,
workspace.setting_validate_certificates,
client_cert.clone(),
)
.await;
@@ -294,7 +304,7 @@ async fn cmd_grpc_go<R: Runtime>(
app_handle.db().upsert_grpc_connection(
&GrpcConnection {
elapsed: start.elapsed().as_millis() as i32,
error: Some(err.clone()),
error: Some(err.to_string()),
state: GrpcConnectionState::Closed,
..conn.clone()
},
@@ -425,7 +435,9 @@ async fn cmd_grpc_go<R: Runtime>(
match (method_desc.is_client_streaming(), method_desc.is_server_streaming()) {
(true, true) => (
Some(
connection.streaming(&service, &method, in_msg_stream, &metadata).await,
connection
.streaming(&service, &method, in_msg_stream, &metadata, client_cert)
.await,
),
None,
),
@@ -433,7 +445,13 @@ async fn cmd_grpc_go<R: Runtime>(
None,
Some(
connection
.client_streaming(&service, &method, in_msg_stream, &metadata)
.client_streaming(
&service,
&method,
in_msg_stream,
&metadata,
client_cert,
)
.await,
),
),
@@ -441,9 +459,12 @@ async fn cmd_grpc_go<R: Runtime>(
Some(connection.server_streaming(&service, &method, &msg, &metadata).await),
None,
),
(false, false) => {
(None, Some(connection.unary(&service, &method, &msg, &metadata).await))
}
(false, false) => (
None,
Some(
connection.unary(&service, &method, &msg, &metadata, client_cert).await,
),
),
};
if !method_desc.is_client_streaming() {
@@ -503,7 +524,7 @@ async fn cmd_grpc_go<R: Runtime>(
)
.unwrap();
}
Some(Err(e)) => {
Some(Err(yaak_grpc::error::Error::GrpcStreamError(e))) => {
app_handle
.db()
.upsert_grpc_event(
@@ -528,6 +549,21 @@ async fn cmd_grpc_go<R: Runtime>(
)
.unwrap();
}
Some(Err(e)) => {
app_handle
.db()
.upsert_grpc_event(
&GrpcEvent {
error: Some(e.to_string()),
status: Some(Code::Unknown as i32),
content: "Failed to connect".to_string(),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
&UpdateSource::from_window(&window),
)
.unwrap();
}
None => {
// Server streaming doesn't return the initial message
}
@@ -554,7 +590,7 @@ async fn cmd_grpc_go<R: Runtime>(
.unwrap();
stream.into_inner()
}
Some(Err(e)) => {
Some(Err(yaak_grpc::error::Error::GrpcStreamError(e))) => {
warn!("GRPC stream error {e:?}");
app_handle
.db()
@@ -581,6 +617,22 @@ async fn cmd_grpc_go<R: Runtime>(
.unwrap();
return;
}
Some(Err(e)) => {
app_handle
.db()
.upsert_grpc_event(
&GrpcEvent {
error: Some(e.to_string()),
status: Some(Code::Unknown as i32),
content: "Failed to connect".to_string(),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
&UpdateSource::from_window(&window),
)
.unwrap();
return;
}
None => return,
};

View File

@@ -24,4 +24,5 @@ tokio-stream = "0.1.14"
tonic = { version = "0.12.3", default-features = false, features = ["transport"] }
tonic-reflection = "0.12.3"
uuid = { version = "1.7.0", features = ["v4"] }
yaak-http = { workspace = true }
yaak-tls = { workspace = true }
thiserror = "2.0.17"

View File

@@ -1,3 +1,5 @@
use crate::error::Error::GenericError;
use crate::error::Result;
use crate::manager::decorate_req;
use crate::transport::get_transport;
use async_recursion::async_recursion;
@@ -18,6 +20,7 @@ use tonic_reflection::pb::v1::{
};
use tonic_reflection::pb::v1::{ExtensionRequest, FileDescriptorResponse};
use tonic_reflection::pb::{v1, v1alpha};
use yaak_tls::ClientCertificateConfig;
pub struct AutoReflectionClient<T = Client<HttpsConnector<HttpConnector>, BoxBody>> {
use_v1alpha: bool,
@@ -26,20 +29,24 @@ pub struct AutoReflectionClient<T = Client<HttpsConnector<HttpConnector>, BoxBod
}
impl AutoReflectionClient {
pub fn new(uri: &Uri, validate_certificates: bool) -> Self {
pub fn new(
uri: &Uri,
validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>,
) -> Result<Self> {
let client_v1 = v1::server_reflection_client::ServerReflectionClient::with_origin(
get_transport(validate_certificates),
get_transport(validate_certificates, client_cert.clone())?,
uri.clone(),
);
let client_v1alpha = v1alpha::server_reflection_client::ServerReflectionClient::with_origin(
get_transport(validate_certificates),
get_transport(validate_certificates, client_cert.clone())?,
uri.clone(),
);
AutoReflectionClient {
Ok(AutoReflectionClient {
use_v1alpha: false,
client_v1,
client_v1alpha,
}
})
}
#[async_recursion]
@@ -47,36 +54,40 @@ impl AutoReflectionClient {
&mut self,
message: MessageRequest,
metadata: &BTreeMap<String, String>,
) -> Result<MessageResponse, String> {
) -> Result<MessageResponse> {
let reflection_request = ServerReflectionRequest {
host: "".into(), // Doesn't matter
message_request: Some(message.clone()),
};
if self.use_v1alpha {
let mut request = Request::new(tokio_stream::once(to_v1alpha_request(reflection_request)));
decorate_req(metadata, &mut request).map_err(|e| e.to_string())?;
let mut request =
Request::new(tokio_stream::once(to_v1alpha_request(reflection_request)));
decorate_req(metadata, &mut request)?;
self.client_v1alpha
.server_reflection_info(request)
.await
.map_err(|e| match e.code() {
tonic::Code::Unavailable => "Failed to connect to endpoint".to_string(),
tonic::Code::Unauthenticated => "Authentication failed".to_string(),
tonic::Code::DeadlineExceeded => "Deadline exceeded".to_string(),
_ => e.to_string(),
tonic::Code::Unavailable => {
GenericError("Failed to connect to endpoint".to_string())
}
tonic::Code::Unauthenticated => {
GenericError("Authentication failed".to_string())
}
tonic::Code::DeadlineExceeded => GenericError("Deadline exceeded".to_string()),
_ => GenericError(e.to_string()),
})?
.into_inner()
.next()
.await
.expect("steamed response")
.map_err(|e| e.to_string())?
.ok_or(GenericError("Missing reflection message".to_string()))??
.message_response
.ok_or("No reflection response".to_string())
.ok_or(GenericError("No reflection response".to_string()))
.map(|resp| to_v1_msg_response(resp))
} else {
let mut request = Request::new(tokio_stream::once(reflection_request));
decorate_req(metadata, &mut request).map_err(|e| e.to_string())?;
decorate_req(metadata, &mut request)?;
let resp = self.client_v1.server_reflection_info(request).await;
match resp {
@@ -92,18 +103,19 @@ impl AutoReflectionClient {
},
}
.map_err(|e| match e.code() {
tonic::Code::Unavailable => "Failed to connect to endpoint".to_string(),
tonic::Code::Unauthenticated => "Authentication failed".to_string(),
tonic::Code::DeadlineExceeded => "Deadline exceeded".to_string(),
_ => e.to_string(),
tonic::Code::Unavailable => {
GenericError("Failed to connect to endpoint".to_string())
}
tonic::Code::Unauthenticated => GenericError("Authentication failed".to_string()),
tonic::Code::DeadlineExceeded => GenericError("Deadline exceeded".to_string()),
_ => GenericError(e.to_string()),
})?
.into_inner()
.next()
.await
.expect("steamed response")
.map_err(|e| e.to_string())?
.ok_or(GenericError("Missing reflection message".to_string()))??
.message_response
.ok_or("No reflection response".to_string())
.ok_or(GenericError("No reflection response".to_string()))
}
}
}

View File

@@ -0,0 +1,51 @@
use crate::manager::GrpcStreamError;
use serde::{Serialize, Serializer};
use serde_json::Error as SerdeJsonError;
use std::io;
use prost::DecodeError;
use thiserror::Error;
use tonic::Status;
#[derive(Error, Debug)]
pub enum Error {
#[error(transparent)]
TlsError(#[from] yaak_tls::error::Error),
#[error(transparent)]
TonicError(#[from] Status),
#[error("Prost reflect error: {0:?}")]
ProstReflectError(#[from] prost_reflect::DescriptorError),
#[error(transparent)]
DeserializerError(#[from] SerdeJsonError),
#[error(transparent)]
GrpcStreamError(#[from] GrpcStreamError),
#[error(transparent)]
GrpcDecodeError(#[from] DecodeError),
#[error(transparent)]
GrpcInvalidMetadataKeyError(#[from] tonic::metadata::errors::InvalidMetadataKey),
#[error(transparent)]
GrpcInvalidMetadataValueError(#[from] tonic::metadata::errors::InvalidMetadataValue),
#[error(transparent)]
IOError(#[from] io::Error),
#[error("GRPC error: {0}")]
GenericError(String),
}
impl Serialize for Error {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
pub type Result<T> = std::result::Result<T, Error>;

View File

@@ -9,6 +9,7 @@ pub mod manager;
mod reflection;
mod transport;
mod any;
pub mod error;
pub use tonic::metadata::*;
pub use tonic::Code;

View File

@@ -1,4 +1,6 @@
use crate::codec::DynamicCodec;
use crate::error::Error::GenericError;
use crate::error::Result;
use crate::reflection::{
fill_pool_from_files, fill_pool_from_reflection, method_desc_to_path, reflect_types_for_message,
};
@@ -12,6 +14,9 @@ pub use prost_reflect::DynamicMessage;
use prost_reflect::{DescriptorPool, MethodDescriptor, ServiceDescriptor};
use serde_json::Deserializer;
use std::collections::BTreeMap;
use std::error::Error;
use std::fmt;
use std::fmt::Display;
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::Arc;
@@ -23,6 +28,7 @@ use tonic::body::BoxBody;
use tonic::metadata::{MetadataKey, MetadataValue};
use tonic::transport::Uri;
use tonic::{IntoRequest, IntoStreamingRequest, Request, Response, Status, Streaming};
use yaak_tls::ClientCertificateConfig;
#[derive(Clone)]
pub struct GrpcConnection {
@@ -33,23 +39,34 @@ pub struct GrpcConnection {
}
#[derive(Default, Debug)]
pub struct StreamError {
pub struct GrpcStreamError {
pub message: String,
pub status: Option<Status>,
}
impl From<String> for StreamError {
impl Error for GrpcStreamError {}
impl Display for GrpcStreamError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.status {
Some(status) => write!(f, "[{}] {}", status, self.message),
None => write!(f, "{}", self.message),
}
}
}
impl From<String> for GrpcStreamError {
fn from(value: String) -> Self {
StreamError {
GrpcStreamError {
message: value.to_string(),
status: None,
}
}
}
impl From<Status> for StreamError {
impl From<Status> for GrpcStreamError {
fn from(s: Status) -> Self {
StreamError {
GrpcStreamError {
message: s.message().to_string(),
status: Some(s),
}
@@ -57,16 +74,20 @@ impl From<Status> for StreamError {
}
impl GrpcConnection {
pub async fn method(&self, service: &str, method: &str) -> Result<MethodDescriptor, String> {
pub async fn method(&self, service: &str, method: &str) -> Result<MethodDescriptor> {
let service = self.service(service).await?;
let method =
service.methods().find(|m| m.name() == method).ok_or("Failed to find method")?;
let method = service
.methods()
.find(|m| m.name() == method)
.ok_or(GenericError("Failed to find method".to_string()))?;
Ok(method)
}
async fn service(&self, service: &str) -> Result<ServiceDescriptor, String> {
async fn service(&self, service: &str) -> Result<ServiceDescriptor> {
let pool = self.pool.read().await;
let service = pool.get_service_by_name(service).ok_or("Failed to find service")?;
let service = pool
.get_service_by_name(service)
.ok_or(GenericError("Failed to find service".to_string()))?;
Ok(service)
}
@@ -76,26 +97,27 @@ impl GrpcConnection {
method: &str,
message: &str,
metadata: &BTreeMap<String, String>,
) -> Result<Response<DynamicMessage>, StreamError> {
client_cert: Option<ClientCertificateConfig>,
) -> Result<Response<DynamicMessage>> {
if self.use_reflection {
reflect_types_for_message(self.pool.clone(), &self.uri, message, metadata).await?;
reflect_types_for_message(self.pool.clone(), &self.uri, message, metadata, client_cert)
.await?;
}
let method = &self.method(&service, &method).await?;
let input_message = method.input();
let mut deserializer = Deserializer::from_str(message);
let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)
.map_err(|e| e.to_string())?;
deserializer.end().unwrap();
let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?;
deserializer.end()?;
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
let mut req = req_message.into_request();
decorate_req(metadata, &mut req).map_err(|e| e.to_string())?;
decorate_req(metadata, &mut req)?;
let path = method_desc_to_path(method);
let codec = DynamicCodec::new(method.clone());
client.ready().await.unwrap();
client.ready().await.map_err(|e| GenericError(format!("Failed to connect: {}", e)))?;
Ok(client.unary(req, path, codec).await?)
}
@@ -106,7 +128,8 @@ impl GrpcConnection {
method: &str,
stream: ReceiverStream<String>,
metadata: &BTreeMap<String, String>,
) -> Result<Response<Streaming<DynamicMessage>>, StreamError> {
client_cert: Option<ClientCertificateConfig>,
) -> Result<Response<Streaming<DynamicMessage>>> {
let method = &self.method(&service, &method).await?;
let mapped_stream = {
let input_message = method.input();
@@ -114,15 +137,19 @@ impl GrpcConnection {
let uri = self.uri.clone();
let md = metadata.clone();
let use_reflection = self.use_reflection.clone();
let client_cert = client_cert.clone();
stream.filter_map(move |json| {
let pool = pool.clone();
let uri = uri.clone();
let input_message = input_message.clone();
let md = md.clone();
let use_reflection = use_reflection.clone();
let client_cert = client_cert.clone();
tauri::async_runtime::block_on(async move {
if use_reflection {
if let Err(e) = reflect_types_for_message(pool, &uri, &json, &md).await {
if let Err(e) =
reflect_types_for_message(pool, &uri, &json, &md, client_cert).await
{
warn!("Failed to resolve Any types: {e}");
}
}
@@ -143,9 +170,9 @@ impl GrpcConnection {
let codec = DynamicCodec::new(method.clone());
let mut req = mapped_stream.into_streaming_request();
decorate_req(metadata, &mut req).map_err(|e| e.to_string())?;
decorate_req(metadata, &mut req)?;
client.ready().await.map_err(|e| e.to_string())?;
client.ready().await.map_err(|e| GenericError(format!("Failed to connect: {}", e)))?;
Ok(client.streaming(req, path, codec).await?)
}
@@ -155,7 +182,8 @@ impl GrpcConnection {
method: &str,
stream: ReceiverStream<String>,
metadata: &BTreeMap<String, String>,
) -> Result<Response<DynamicMessage>, StreamError> {
client_cert: Option<ClientCertificateConfig>,
) -> Result<Response<DynamicMessage>> {
let method = &self.method(&service, &method).await?;
let mapped_stream = {
let input_message = method.input();
@@ -163,15 +191,19 @@ impl GrpcConnection {
let uri = self.uri.clone();
let md = metadata.clone();
let use_reflection = self.use_reflection.clone();
let client_cert = client_cert.clone();
stream.filter_map(move |json| {
let pool = pool.clone();
let uri = uri.clone();
let input_message = input_message.clone();
let md = md.clone();
let use_reflection = use_reflection.clone();
let client_cert = client_cert.clone();
tauri::async_runtime::block_on(async move {
if use_reflection {
if let Err(e) = reflect_types_for_message(pool, &uri, &json, &md).await {
if let Err(e) =
reflect_types_for_message(pool, &uri, &json, &md, client_cert).await
{
warn!("Failed to resolve Any types: {e}");
}
}
@@ -192,13 +224,13 @@ impl GrpcConnection {
let codec = DynamicCodec::new(method.clone());
let mut req = mapped_stream.into_streaming_request();
decorate_req(metadata, &mut req).map_err(|e| e.to_string())?;
decorate_req(metadata, &mut req)?;
client.ready().await.unwrap();
client.client_streaming(req, path, codec).await.map_err(|e| StreamError {
client.ready().await.map_err(|e| GenericError(format!("Failed to connect: {}", e)))?;
Ok(client.client_streaming(req, path, codec).await.map_err(|e| GrpcStreamError {
message: e.message().to_string(),
status: Some(e),
})
})?)
}
pub async fn server_streaming(
@@ -207,23 +239,22 @@ impl GrpcConnection {
method: &str,
message: &str,
metadata: &BTreeMap<String, String>,
) -> Result<Response<Streaming<DynamicMessage>>, StreamError> {
) -> Result<Response<Streaming<DynamicMessage>>> {
let method = &self.method(&service, &method).await?;
let input_message = method.input();
let mut deserializer = Deserializer::from_str(message);
let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)
.map_err(|e| e.to_string())?;
deserializer.end().unwrap();
let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?;
deserializer.end()?;
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
let mut req = req_message.into_request();
decorate_req(metadata, &mut req).map_err(|e| e.to_string())?;
decorate_req(metadata, &mut req)?;
let path = method_desc_to_path(method);
let codec = DynamicCodec::new(method.clone());
client.ready().await.map_err(|e| e.to_string())?;
client.ready().await.map_err(|e| GenericError(format!("Failed to connect: {}", e)))?;
Ok(client.server_streaming(req, path, codec).await?)
}
}
@@ -257,7 +288,8 @@ impl GrpcHandle {
proto_files: &Vec<PathBuf>,
metadata: &BTreeMap<String, String>,
validate_certificates: bool,
) -> Result<bool, String> {
client_cert: Option<ClientCertificateConfig>,
) -> Result<bool> {
let server_reflection = proto_files.is_empty();
let key = make_pool_key(id, uri, proto_files);
@@ -268,7 +300,7 @@ impl GrpcHandle {
let pool = if server_reflection {
let full_uri = uri_from_str(uri)?;
fill_pool_from_reflection(&full_uri, metadata, validate_certificates).await
fill_pool_from_reflection(&full_uri, metadata, validate_certificates, client_cert).await
} else {
fill_pool_from_files(&self.app_handle, proto_files).await
}?;
@@ -284,15 +316,19 @@ impl GrpcHandle {
proto_files: &Vec<PathBuf>,
metadata: &BTreeMap<String, String>,
validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>,
skip_cache: bool,
) -> Result<Vec<ServiceDefinition>, String> {
) -> Result<Vec<ServiceDefinition>> {
// Ensure we have a pool; reflect only if missing
if skip_cache || self.get_pool(id, uri, proto_files).is_none() {
info!("Reflecting gRPC services for {} at {}", id, uri);
self.reflect(id, uri, proto_files, metadata, validate_certificates).await?;
self.reflect(id, uri, proto_files, metadata, validate_certificates, client_cert)
.await?;
}
let pool = self.get_pool(id, uri, proto_files).ok_or("Failed to get pool".to_string())?;
let pool = self
.get_pool(id, uri, proto_files)
.ok_or(GenericError("Failed to get pool".to_string()))?;
Ok(self.services_from_pool(&pool))
}
@@ -313,7 +349,7 @@ impl GrpcHandle {
&pool,
input_message,
))
.unwrap(),
.expect("Failed to serialize JSON schema"),
})
}
def
@@ -328,14 +364,26 @@ impl GrpcHandle {
proto_files: &Vec<PathBuf>,
metadata: &BTreeMap<String, String>,
validate_certificates: bool,
) -> Result<GrpcConnection, String> {
client_cert: Option<ClientCertificateConfig>,
) -> Result<GrpcConnection> {
let use_reflection = proto_files.is_empty();
if self.get_pool(id, uri, proto_files).is_none() {
self.reflect(id, uri, proto_files, metadata, validate_certificates).await?;
self.reflect(
id,
uri,
proto_files,
metadata,
validate_certificates,
client_cert.clone(),
)
.await?;
}
let pool = self.get_pool(id, uri, proto_files).ok_or("Failed to get pool")?.clone();
let pool = self
.get_pool(id, uri, proto_files)
.ok_or(GenericError("Failed to get pool".to_string()))?
.clone();
let uri = uri_from_str(uri)?;
let conn = get_transport(validate_certificates);
let conn = get_transport(validate_certificates, client_cert.clone())?;
Ok(GrpcConnection {
pool: Arc::new(RwLock::new(pool)),
use_reflection,
@@ -352,22 +400,20 @@ impl GrpcHandle {
pub(crate) fn decorate_req<T>(
metadata: &BTreeMap<String, String>,
req: &mut Request<T>,
) -> Result<(), String> {
) -> Result<()> {
for (k, v) in metadata {
req.metadata_mut().insert(
MetadataKey::from_str(k.as_str()).map_err(|e| e.to_string())?,
MetadataValue::from_str(v.as_str()).map_err(|e| e.to_string())?,
);
req.metadata_mut()
.insert(MetadataKey::from_str(k.as_str())?, MetadataValue::from_str(v.as_str())?);
}
Ok(())
}
fn uri_from_str(uri_str: &str) -> Result<Uri, String> {
fn uri_from_str(uri_str: &str) -> Result<Uri> {
match Uri::from_str(uri_str) {
Ok(uri) => Ok(uri),
Err(err) => {
// Uri::from_str basically only returns "invalid format" so we add more context here
Err(format!("Failed to parse URL, {}", err.to_string()))
Err(GenericError(format!("Failed to parse URL, {}", err.to_string())))
}
}
}

View File

@@ -1,5 +1,7 @@
use crate::any::collect_any_types;
use crate::client::AutoReflectionClient;
use crate::error::Error::GenericError;
use crate::error::Result;
use anyhow::anyhow;
use async_recursion::async_recursion;
use log::{debug, info, warn};
@@ -21,11 +23,12 @@ use tonic::codegen::http::uri::PathAndQuery;
use tonic::transport::Uri;
use tonic_reflection::pb::v1::server_reflection_request::MessageRequest;
use tonic_reflection::pb::v1::server_reflection_response::MessageResponse;
use yaak_tls::ClientCertificateConfig;
pub async fn fill_pool_from_files(
app_handle: &AppHandle,
paths: &Vec<PathBuf>,
) -> Result<DescriptorPool, String> {
) -> Result<DescriptorPool> {
let mut pool = DescriptorPool::new();
let random_file_name = format!("{}.desc", uuid::Uuid::new_v4());
let desc_path = temp_dir().join(random_file_name);
@@ -103,18 +106,18 @@ pub async fn fill_pool_from_files(
.expect("yaakprotoc failed to run");
if !out.status.success() {
return Err(format!(
return Err(GenericError(format!(
"protoc failed with status {}: {}",
out.status.code().unwrap(),
String::from_utf8_lossy(out.stderr.as_slice())
));
)));
}
let bytes = fs::read(desc_path).await.map_err(|e| e.to_string())?;
let fdp = FileDescriptorSet::decode(bytes.deref()).map_err(|e| e.to_string())?;
pool.add_file_descriptor_set(fdp).map_err(|e| e.to_string())?;
let bytes = fs::read(desc_path).await?;
let fdp = FileDescriptorSet::decode(bytes.deref())?;
pool.add_file_descriptor_set(fdp)?;
fs::remove_file(desc_path).await.map_err(|e| e.to_string())?;
fs::remove_file(desc_path).await?;
Ok(pool)
}
@@ -123,9 +126,10 @@ pub async fn fill_pool_from_reflection(
uri: &Uri,
metadata: &BTreeMap<String, String>,
validate_certificates: bool,
) -> Result<DescriptorPool, String> {
client_cert: Option<ClientCertificateConfig>,
) -> Result<DescriptorPool> {
let mut pool = DescriptorPool::new();
let mut client = AutoReflectionClient::new(uri, validate_certificates);
let mut client = AutoReflectionClient::new(uri, validate_certificates, client_cert)?;
for service in list_services(&mut client, metadata).await? {
if service == "grpc.reflection.v1alpha.ServerReflection" {
@@ -144,7 +148,7 @@ pub async fn fill_pool_from_reflection(
async fn list_services(
client: &mut AutoReflectionClient,
metadata: &BTreeMap<String, String>,
) -> Result<Vec<String>, String> {
) -> Result<Vec<String>> {
let response =
client.send_reflection_request(MessageRequest::ListServices("".into()), metadata).await?;
@@ -171,7 +175,7 @@ async fn file_descriptor_set_from_service_name(
{
Ok(resp) => resp,
Err(e) => {
warn!("Error fetching file descriptor for service {}: {}", service_name, e);
warn!("Error fetching file descriptor for service {}: {:?}", service_name, e);
return;
}
};
@@ -195,7 +199,8 @@ pub(crate) async fn reflect_types_for_message(
uri: &Uri,
json: &str,
metadata: &BTreeMap<String, String>,
) -> Result<(), String> {
client_cert: Option<ClientCertificateConfig>,
) -> Result<()> {
// 1. Collect all Any types in the JSON
let mut extra_types = Vec::new();
collect_any_types(json, &mut extra_types);
@@ -204,7 +209,7 @@ pub(crate) async fn reflect_types_for_message(
return Ok(()); // nothing to do
}
let mut client = AutoReflectionClient::new(uri, false);
let mut client = AutoReflectionClient::new(uri, false, client_cert)?;
for extra_type in extra_types {
{
let guard = pool.read().await;
@@ -217,9 +222,9 @@ pub(crate) async fn reflect_types_for_message(
let resp = match client.send_reflection_request(req, metadata).await {
Ok(r) => r,
Err(e) => {
return Err(format!(
"Error sending reflection request for @type \"{extra_type}\": {e}",
));
return Err(GenericError(format!(
"Error sending reflection request for @type \"{extra_type}\": {e:?}",
)));
}
};
let files = match resp {
@@ -286,7 +291,7 @@ async fn file_descriptor_set_by_filename(
panic!("Expected a FileDescriptorResponse variant")
}
Err(e) => {
warn!("Error fetching file descriptor for {}: {}", filename, e);
warn!("Error fetching file descriptor for {}: {:?}", filename, e);
return;
}
};

View File

@@ -1,25 +1,41 @@
use crate::error::Result;
use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder};
use hyper_util::client::legacy::connect::HttpConnector;
use hyper_util::client::legacy::Client;
use hyper_util::rt::TokioExecutor;
use log::info;
use tonic::body::BoxBody;
use yaak_tls::{get_tls_config, ClientCertificateConfig};
// I think ALPN breaks this because we're specifying http2_only
const WITH_ALPN: bool = false;
pub(crate) fn get_transport(validate_certificates: bool) -> Client<HttpsConnector<HttpConnector>, BoxBody> {
let tls_config = yaak_http::tls::get_config(validate_certificates, WITH_ALPN);
pub(crate) fn get_transport(
validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>,
) -> Result<Client<HttpsConnector<HttpConnector>, BoxBody>> {
let tls_config =
get_tls_config(validate_certificates, WITH_ALPN, client_cert.clone())?;
let mut http = HttpConnector::new();
http.enforce_http(false);
let connector =
HttpsConnectorBuilder::new().with_tls_config(tls_config).https_or_http().enable_http2().build();
let connector = HttpsConnectorBuilder::new()
.with_tls_config(tls_config)
.https_or_http()
.enable_http2()
.build();
let client = Client::builder(TokioExecutor::new())
.pool_max_idle_per_host(0)
.http2_only(true)
.build(connector);
client
info!(
"Created gRPC client validate_certs={} client_cert={}",
validate_certificates,
client_cert.is_some()
);
Ok(client)
}

View File

@@ -5,17 +5,16 @@ edition = "2024"
publish = false
[dependencies]
yaak-models = { workspace = true }
hyper-util = { version = "0.1.17", default-features = false, features = ["client-legacy"] }
log = { workspace = true }
regex = "1.11.1"
rustls = { workspace = true, default-features = false, features = ["ring"] }
rustls-platform-verifier = { workspace = true }
urlencoding = "2.1.3"
tauri = { workspace = true }
tokio = { workspace = true }
reqwest = { workspace = true, features = ["multipart", "cookies", "gzip", "brotli", "deflate", "json", "rustls-tls-manual-roots-no-provider", "socks", "http2"] }
reqwest_cookie_store = { workspace = true }
thiserror = { workspace = true }
serde = { workspace = true, features = ["derive"] }
hyper-util = { version = "0.1.17", default-features = false, features = ["client-legacy"] }
tauri = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tower-service = "0.3.3"
log = { workspace = true }
urlencoding = "2.1.3"
yaak-models = { workspace = true }
yaak-tls = { workspace = true }

View File

@@ -1,12 +1,12 @@
use crate::dns::LocalhostResolver;
use crate::error::Result;
use crate::tls;
use log::{debug, warn};
use log::{debug, info, warn};
use reqwest::redirect::Policy;
use reqwest::{Client, Proxy};
use reqwest_cookie_store::CookieStoreMutex;
use std::sync::Arc;
use std::time::Duration;
use yaak_tls::{ClientCertificateConfig, get_tls_config};
#[derive(Clone)]
pub struct HttpConnectionProxySettingAuth {
@@ -28,11 +28,13 @@ pub enum HttpConnectionProxySetting {
#[derive(Clone)]
pub struct HttpConnectionOptions {
pub id: String,
pub follow_redirects: bool,
pub validate_certificates: bool,
pub proxy: HttpConnectionProxySetting,
pub cookie_provider: Option<Arc<CookieStoreMutex>>,
pub timeout: Option<Duration>,
pub client_certificate: Option<ClientCertificateConfig>,
}
impl HttpConnectionOptions {
@@ -45,8 +47,10 @@ impl HttpConnectionOptions {
.referer(false)
.tls_info(true);
// Configure TLS
client = client.use_preconfigured_tls(tls::get_config(self.validate_certificates, true));
// Configure TLS with optional client certificate
let config =
get_tls_config(self.validate_certificates, true, self.client_certificate.clone())?;
client = client.use_preconfigured_tls(config);
// Configure DNS resolver
client = client.dns_resolver(LocalhostResolver::new());
@@ -85,6 +89,12 @@ impl HttpConnectionOptions {
client = client.timeout(d);
}
info!(
"Building new HTTP client validate_certificates={} client_cert={}",
self.validate_certificates,
self.client_certificate.is_some()
);
Ok(client.build()?)
}
}

View File

@@ -3,8 +3,11 @@ use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
#[error(transparent)]
#[error("Client error: {0:?}")]
Client(#[from] reqwest::Error),
#[error(transparent)]
TlsError(#[from] yaak_tls::error::Error),
}
impl Serialize for Error {

View File

@@ -7,7 +7,6 @@ pub mod dns;
pub mod error;
pub mod manager;
pub mod path_placeholders;
pub mod tls;
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("yaak-http")

View File

@@ -20,19 +20,19 @@ impl HttpConnectionManager {
}
}
pub async fn get_client(&self, id: &str, opt: &HttpConnectionOptions) -> Result<Client> {
pub async fn get_client(&self, opt: &HttpConnectionOptions) -> Result<Client> {
let mut connections = self.connections.write().await;
let id = opt.id.clone();
// Clean old connections
connections.retain(|_, (_, last_used)| last_used.elapsed() <= self.ttl);
if let Some((c, last_used)) = connections.get_mut(id) {
if let Some((c, last_used)) = connections.get_mut(&id) {
info!("Re-using HTTP client {id}");
*last_used = Instant::now();
return Ok(c.clone());
}
info!("Building new HTTP client {id}");
let c = opt.build_client()?;
connections.insert(id.into(), (c.clone(), Instant::now()));
Ok(c)

View File

@@ -1,81 +0,0 @@
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::crypto::ring;
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme};
use rustls_platform_verifier::BuilderVerifierExt;
use std::sync::Arc;
pub fn get_config(validate_certificates: bool, with_alpn: bool) -> ClientConfig {
let arc_crypto_provider = Arc::new(ring::default_provider());
let config_builder = ClientConfig::builder_with_provider(arc_crypto_provider)
.with_safe_default_protocol_versions()
.unwrap();
let mut client = if validate_certificates {
// Use platform-native verifier to validate certificates
config_builder.with_platform_verifier().unwrap().with_no_client_auth()
} else {
config_builder
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoVerifier))
.with_no_client_auth()
};
if with_alpn {
client.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
}
client
}
// Copied from reqwest: https://github.com/seanmonstar/reqwest/blob/595c80b1fbcdab73ac2ae93e4edc3406f453df25/src/tls.rs#L608
#[derive(Debug)]
struct NoVerifier;
impl ServerCertVerifier for NoVerifier {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer,
_intermediates: &[CertificateDer],
_server_name: &ServerName,
_ocsp_response: &[u8],
_now: UnixTime,
) -> Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
vec![
SignatureScheme::RSA_PKCS1_SHA1,
SignatureScheme::ECDSA_SHA1_Legacy,
SignatureScheme::RSA_PKCS1_SHA256,
SignatureScheme::ECDSA_NISTP256_SHA256,
SignatureScheme::RSA_PKCS1_SHA384,
SignatureScheme::ECDSA_NISTP384_SHA384,
SignatureScheme::RSA_PKCS1_SHA512,
SignatureScheme::ECDSA_NISTP521_SHA512,
SignatureScheme::RSA_PSS_SHA256,
SignatureScheme::RSA_PSS_SHA384,
SignatureScheme::RSA_PSS_SHA512,
SignatureScheme::ED25519,
SignatureScheme::ED448,
]
}
}

View File

@@ -2,6 +2,8 @@
export type AnyModel = CookieJar | Environment | Folder | GraphQlIntrospection | GrpcConnection | GrpcEvent | GrpcRequest | HttpRequest | HttpResponse | KeyValue | Plugin | Settings | SyncState | WebsocketConnection | WebsocketEvent | WebsocketRequest | Workspace | WorkspaceMeta;
export type ClientCertificate = { host: string, port: number | null, crtFile: string | null, keyFile: string | null, pfxFile: string | null, passphrase: string | null, enabled?: boolean, };
export type Cookie = { raw_cookie: string, domain: CookieDomain, expires: CookieExpires, path: [string, boolean], };
export type CookieDomain = { "HostOnly": string } | { "Suffix": string } | "NotPresent" | "Empty";
@@ -62,7 +64,7 @@ export type ProxySetting = { "type": "enabled", http: string, https: string, aut
export type ProxySettingAuth = { user: string, password: string, };
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, coloredMethods: boolean, editorFont: string | null, editorFontSize: number, editorKeymap: EditorKeymap, editorSoftWrap: boolean, hideWindowControls: boolean, useNativeTitlebar: boolean, interfaceFont: string | null, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, hideLicenseBadge: boolean, autoupdate: boolean, autoDownloadUpdates: boolean, checkNotifications: boolean, };
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, clientCertificates: Array<ClientCertificate>, coloredMethods: boolean, editorFont: string | null, editorFontSize: number, editorKeymap: EditorKeymap, editorSoftWrap: boolean, hideWindowControls: boolean, useNativeTitlebar: boolean, interfaceFont: string | null, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, hideLicenseBadge: boolean, autoupdate: boolean, autoDownloadUpdates: boolean, checkNotifications: boolean, };
export type SyncState = { model: "sync_state", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, };

View File

@@ -0,0 +1 @@
ALTER TABLE settings ADD COLUMN client_certificates TEXT DEFAULT '[]' NOT NULL;

View File

@@ -52,6 +52,26 @@ pub struct ProxySettingAuth {
pub password: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct ClientCertificate {
pub host: String,
#[serde(default)]
pub port: Option<i32>,
#[serde(default)]
pub crt_file: Option<String>,
#[serde(default)]
pub key_file: Option<String>,
#[serde(default)]
pub pfx_file: Option<String>,
#[serde(default)]
pub passphrase: Option<String>,
#[serde(default = "default_true")]
#[ts(optional, as = "Option<bool>")]
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_models.ts")]
@@ -106,6 +126,7 @@ pub struct Settings {
pub updated_at: NaiveDateTime,
pub appearance: String,
pub client_certificates: Vec<ClientCertificate>,
pub colored_methods: bool,
pub editor_font: Option<String>,
pub editor_font_size: i32,
@@ -158,10 +179,12 @@ impl UpsertModelInfo for Settings {
None => None,
Some(p) => Some(serde_json::to_string(&p)?),
};
let client_certificates = serde_json::to_string(&self.client_certificates)?;
Ok(vec![
(CreatedAt, upsert_date(source, self.created_at)),
(UpdatedAt, upsert_date(source, self.updated_at)),
(Appearance, self.appearance.as_str().into()),
(ClientCertificates, client_certificates.into()),
(EditorFontSize, self.editor_font_size.into()),
(EditorKeymap, self.editor_keymap.to_string().into()),
(EditorSoftWrap, self.editor_soft_wrap.into()),
@@ -188,6 +211,7 @@ impl UpsertModelInfo for Settings {
vec![
SettingsIden::UpdatedAt,
SettingsIden::Appearance,
SettingsIden::ClientCertificates,
SettingsIden::EditorFontSize,
SettingsIden::EditorKeymap,
SettingsIden::EditorSoftWrap,
@@ -215,6 +239,7 @@ impl UpsertModelInfo for Settings {
Self: Sized,
{
let proxy: Option<String> = row.get("proxy")?;
let client_certificates: String = row.get("client_certificates")?;
let editor_keymap: String = row.get("editor_keymap")?;
Ok(Self {
id: row.get("id")?,
@@ -222,6 +247,7 @@ impl UpsertModelInfo for Settings {
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
appearance: row.get("appearance")?,
client_certificates: serde_json::from_str(&client_certificates).unwrap_or_default(),
editor_font_size: row.get("editor_font_size")?,
editor_font: row.get("editor_font")?,
editor_keymap: EditorKeymap::from_str(editor_keymap.as_str()).unwrap(),

View File

@@ -18,6 +18,7 @@ impl<'a> DbContext<'a> {
updated_at: Default::default(),
appearance: "system".to_string(),
client_certificates: Vec::new(),
editor_font_size: 12,
editor_font: None,
editor_keymap: EditorKeymap::Default,

View File

@@ -0,0 +1,16 @@
[package]
name = "yaak-tls"
version = "0.1.0"
edition = "2021"
publish = false
[dependencies]
log = { workspace = true }
p12 = "0.6.3"
rustls = { workspace = true, default-features = false, features = ["ring"] }
rustls-pemfile = "2"
rustls-platform-verifier = { workspace = true }
serde = { workspace = true, features = ["derive"] }
thiserror = "2.0.17"
url = "2.5"
yaak-models = { workspace = true }

View File

@@ -0,0 +1,26 @@
use serde::{Serialize, Serializer};
use std::io;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
#[error("Rustls error: {0}")]
RustlsError(#[from] rustls::Error),
#[error("I/O error: {0}")]
IOError(#[from] io::Error),
#[error("TLS error: {0}")]
GenericError(String),
}
impl Serialize for Error {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
pub type Result<T> = std::result::Result<T, Error>;

View File

@@ -0,0 +1,279 @@
use crate::error::Error::GenericError;
use crate::error::Result;
use log::debug;
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::crypto::ring;
use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime};
use rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme};
use rustls_platform_verifier::BuilderVerifierExt;
use std::fs;
use std::io::BufReader;
use std::path::Path;
use std::str::FromStr;
use std::sync::Arc;
pub mod error;
#[derive(Clone, Default)]
pub struct ClientCertificateConfig {
pub crt_file: Option<String>,
pub key_file: Option<String>,
pub pfx_file: Option<String>,
pub passphrase: Option<String>,
}
pub fn get_tls_config(
validate_certificates: bool,
with_alpn: bool,
client_cert: Option<ClientCertificateConfig>,
) -> Result<ClientConfig> {
let maybe_client_cert = load_client_cert(client_cert)?;
let mut client = if validate_certificates {
build_with_validation(maybe_client_cert)
} else {
build_without_validation(maybe_client_cert)
}?;
if with_alpn {
client.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
}
Ok(client)
}
fn build_with_validation(
client_cert: Option<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)>,
) -> Result<ClientConfig> {
let arc_crypto_provider = Arc::new(ring::default_provider());
let builder = ClientConfig::builder_with_provider(arc_crypto_provider)
.with_safe_default_protocol_versions()?
.with_platform_verifier()?;
if let Some((certs, key)) = client_cert {
return Ok(builder.with_client_auth_cert(certs, key)?);
}
Ok(builder.with_no_client_auth())
}
fn build_without_validation(
client_cert: Option<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)>,
) -> Result<ClientConfig> {
let arc_crypto_provider = Arc::new(ring::default_provider());
let builder = ClientConfig::builder_with_provider(arc_crypto_provider)
.with_safe_default_protocol_versions()?
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoVerifier));
if let Some((certs, key)) = client_cert {
return Ok(builder.with_client_auth_cert(certs, key)?);
}
Ok(builder.with_no_client_auth())
}
fn load_client_cert(
client_cert: Option<ClientCertificateConfig>,
) -> Result<Option<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)>> {
let config = match client_cert {
None => return Ok(None),
Some(c) => c,
};
// Try PFX/PKCS12 first
if let Some(pfx_path) = &config.pfx_file {
if !pfx_path.is_empty() {
return Ok(Some(load_pkcs12(pfx_path, config.passphrase.as_deref().unwrap_or(""))?));
}
}
// Try CRT + KEY files
if let (Some(crt_path), Some(key_path)) = (&config.crt_file, &config.key_file) {
if !crt_path.is_empty() && !key_path.is_empty() {
return Ok(Some(load_pem_files(crt_path, key_path)?));
}
}
Ok(None)
}
fn load_pem_files(
crt_path: &str,
key_path: &str,
) -> Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)> {
// Load certificates
let crt_file = fs::File::open(Path::new(crt_path))?;
let mut crt_reader = BufReader::new(crt_file);
let certs: Vec<CertificateDer<'static>> =
rustls_pemfile::certs(&mut crt_reader).filter_map(|r| r.ok()).collect();
if certs.is_empty() {
return Err(GenericError("No certificates found in CRT file".to_string()));
}
// Load private key
let key_data = fs::read(Path::new(key_path))?;
let key = load_private_key(&key_data)?;
Ok((certs, key))
}
fn load_private_key(data: &[u8]) -> Result<PrivateKeyDer<'static>> {
let mut reader = BufReader::new(data);
// Try PKCS8 first
if let Some(key) = rustls_pemfile::pkcs8_private_keys(&mut reader).filter_map(|r| r.ok()).next()
{
return Ok(PrivateKeyDer::Pkcs8(key));
}
// Reset reader and try RSA
let mut reader = BufReader::new(data);
if let Some(key) = rustls_pemfile::rsa_private_keys(&mut reader).filter_map(|r| r.ok()).next() {
return Ok(PrivateKeyDer::Pkcs1(key));
}
// Reset reader and try EC
let mut reader = BufReader::new(data);
if let Some(key) = rustls_pemfile::ec_private_keys(&mut reader).filter_map(|r| r.ok()).next() {
return Ok(PrivateKeyDer::Sec1(key));
}
Err(GenericError("Could not parse private key".to_string()))
}
fn load_pkcs12(
path: &str,
passphrase: &str,
) -> Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)> {
let data = fs::read(Path::new(path))?;
let pfx = p12::PFX::parse(&data)
.map_err(|e| GenericError(format!("Failed to parse PFX: {:?}", e)))?;
let keys = pfx
.key_bags(passphrase)
.map_err(|e| GenericError(format!("Failed to extract keys: {:?}", e)))?;
let certs = pfx
.cert_x509_bags(passphrase)
.map_err(|e| GenericError(format!("Failed to extract certs: {:?}", e)))?;
if keys.is_empty() {
return Err(GenericError("No private key found in PFX".to_string()));
}
if certs.is_empty() {
return Err(GenericError("No certificates found in PFX".to_string()));
}
// Convert certificates - p12 crate returns Vec<u8> for each cert
let cert_ders: Vec<CertificateDer<'static>> =
certs.into_iter().map(|c| CertificateDer::from(c)).collect();
// Convert key - the p12 crate returns raw key bytes
let key_bytes = keys.into_iter().next().unwrap();
let key = PrivateKeyDer::Pkcs8(key_bytes.into());
Ok((cert_ders, key))
}
// Copied from reqwest: https://github.com/seanmonstar/reqwest/blob/595c80b1fbcdab73ac2ae93e4edc3406f453df25/src/tls.rs#L608
#[derive(Debug)]
struct NoVerifier;
impl ServerCertVerifier for NoVerifier {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer,
_intermediates: &[CertificateDer],
_server_name: &ServerName,
_ocsp_response: &[u8],
_now: UnixTime,
) -> std::result::Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer,
_dss: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer,
_dss: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
vec![
SignatureScheme::RSA_PKCS1_SHA1,
SignatureScheme::ECDSA_SHA1_Legacy,
SignatureScheme::RSA_PKCS1_SHA256,
SignatureScheme::ECDSA_NISTP256_SHA256,
SignatureScheme::RSA_PKCS1_SHA384,
SignatureScheme::ECDSA_NISTP384_SHA384,
SignatureScheme::RSA_PKCS1_SHA512,
SignatureScheme::ECDSA_NISTP521_SHA512,
SignatureScheme::RSA_PSS_SHA256,
SignatureScheme::RSA_PSS_SHA384,
SignatureScheme::RSA_PSS_SHA512,
SignatureScheme::ED25519,
SignatureScheme::ED448,
]
}
}
pub fn find_client_certificate(
url_string: &str,
certificates: &[yaak_models::models::ClientCertificate],
) -> Option<ClientCertificateConfig> {
let url = url::Url::from_str(url_string).ok()?;
let host = url.host_str()?;
let port = url.port_or_known_default();
for cert in certificates {
if !cert.enabled {
debug!("Client certificate is disabled, skipping");
continue;
}
// Match host (case-insensitive)
if !cert.host.eq_ignore_ascii_case(host) {
debug!("Client certificate host does not match {} != {} (cert)", host, cert.host);
continue;
}
// Match port if specified in the certificate config
let cert_port = cert.port.unwrap_or(443);
if let Some(url_port) = port {
if cert_port != url_port as i32 {
debug!(
"Client certificate port does not match {} != {} (cert)",
url_port, cert_port
);
continue;
}
}
// Found a matching certificate
debug!("Found matching client certificate host={} port={}", host, port.unwrap_or(443));
return Some(ClientCertificateConfig {
crt_file: cert.crt_file.clone(),
key_file: cert.key_file.clone(),
pfx_file: cert.pfx_file.clone(),
passphrase: cert.passphrase.clone(),
});
}
debug!("No matching client certificate found for {}", url_string);
None
}

View File

@@ -8,7 +8,7 @@ publish = false
[dependencies]
futures-util = "0.3.31"
log = { workspace = true }
md5 = "0.7.0"
md5 = "0.8.0"
reqwest_cookie_store = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
@@ -17,6 +17,7 @@ thiserror = { workspace = true }
tokio = { workspace = true, features = ["macros", "time", "test-util"] }
tokio-tungstenite = { version = "0.26.2", default-features = false, features = ["rustls-tls-native-roots", "connect"] }
yaak-http = { workspace = true }
yaak-tls = { workspace = true }
yaak-models = { workspace = true }
yaak-plugins = { workspace = true }
yaak-templates = { workspace = true }

View File

@@ -23,6 +23,7 @@ use yaak_plugins::events::{
use yaak_plugins::manager::PluginManager;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_templates::{RenderErrorBehavior, RenderOptions};
use yaak_tls::find_client_certificate;
#[tauri::command]
pub(crate) async fn upsert_request<R: Runtime>(
@@ -196,6 +197,7 @@ pub(crate) async fn connect<R: Runtime>(
environment_id,
)?;
let workspace = app_handle.db().get_workspace(&unrendered_request.workspace_id)?;
let settings = app_handle.db().get_settings();
let (resolved_request, auth_context_id) =
resolve_websocket_request(&window, &unrendered_request)?;
let request = render_websocket_request(
@@ -363,6 +365,8 @@ pub(crate) async fn connect<R: Runtime>(
}
}
let client_cert = find_client_certificate(url.as_str(), &settings.client_certificates);
let response = match ws_manager
.connect(
&connection.id,
@@ -370,6 +374,7 @@ pub(crate) async fn connect<R: Runtime>(
headers,
receive_tx,
workspace.setting_validate_certificates,
client_cert,
)
.await
{

View File

@@ -1,3 +1,4 @@
use crate::error::Result;
use log::info;
use std::sync::Arc;
use tauri::http::HeaderMap;
@@ -9,6 +10,7 @@ use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;
use tokio_tungstenite::{
Connector, MaybeTlsStream, WebSocketStream, connect_async_tls_with_config,
};
use yaak_tls::{ClientCertificateConfig, get_tls_config};
// Enabling ALPN breaks websocket requests
const WITH_ALPN: bool = false;
@@ -17,9 +19,10 @@ pub(crate) async fn ws_connect(
url: &str,
headers: HeaderMap<HeaderValue>,
validate_certificates: bool,
) -> crate::error::Result<(WebSocketStream<MaybeTlsStream<TcpStream>>, Response)> {
client_cert: Option<ClientCertificateConfig>,
) -> Result<(WebSocketStream<MaybeTlsStream<TcpStream>>, Response)> {
info!("Connecting to WS {url}");
let tls_config = yaak_http::tls::get_config(validate_certificates, WITH_ALPN);
let tls_config = get_tls_config(validate_certificates, WITH_ALPN, client_cert.clone())?;
let mut req = url.into_client_request()?;
let req_headers = req.headers_mut();
@@ -36,5 +39,12 @@ pub(crate) async fn ws_connect(
Some(Connector::Rustls(Arc::new(tls_config))),
)
.await?;
info!(
"Connected to WS {url} validate_certificates={} client_cert={}",
validate_certificates,
client_cert.is_some()
);
Ok((stream, response))
}

View File

@@ -16,6 +16,9 @@ pub enum Error {
#[error(transparent)]
TemplateError(#[from] yaak_templates::error::Error),
#[error(transparent)]
TlsError(#[from] yaak_tls::error::Error),
#[error("WebSocket error: {0}")]
GenericError(String),
}

View File

@@ -12,6 +12,7 @@ use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::handshake::client::Response;
use tokio_tungstenite::tungstenite::http::{HeaderMap, HeaderValue};
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
use yaak_tls::ClientCertificateConfig;
#[derive(Clone)]
pub struct WebsocketManager {
@@ -35,10 +36,12 @@ impl WebsocketManager {
headers: HeaderMap<HeaderValue>,
receive_tx: mpsc::Sender<Message>,
validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>,
) -> Result<Response> {
let tx = receive_tx.clone();
let (stream, response) = ws_connect(url, headers, validate_certificates).await?;
let (stream, response) =
ws_connect(url, headers, validate_certificates, client_cert).await?;
let (write, mut read) = stream.split();
self.connections.lock().await.insert(id.to_string(), write);

View File

@@ -1,15 +1,19 @@
import { useSearch } from '@tanstack/react-router';
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import { type } from '@tauri-apps/plugin-os';
import { useLicense } from '@yaakapp-internal/license';
import { pluginsAtom, settingsAtom } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import { useState } from 'react';
import { useKeyPressEvent } from 'react-use';
import { appInfo } from '../../lib/appInfo';
import { capitalize } from '../../lib/capitalize';
import { CountBadge } from '../core/CountBadge';
import { HStack } from '../core/Stacks';
import type { TabItem } from '../core/Tabs/Tabs';
import { TabContent, Tabs } from '../core/Tabs/Tabs';
import { TabContent, type TabItem, Tabs } from '../core/Tabs/Tabs';
import { HeaderSize } from '../HeaderSize';
import { SettingsCertificates } from './SettingsCertificates';
import { SettingsGeneral } from './SettingsGeneral';
import { SettingsInterface } from './SettingsInterface';
import { SettingsLicense } from './SettingsLicense';
@@ -25,14 +29,26 @@ const TAB_GENERAL = 'general';
const TAB_INTERFACE = 'interface';
const TAB_THEME = 'theme';
const TAB_PROXY = 'proxy';
const TAB_CERTIFICATES = 'certificates';
const TAB_PLUGINS = 'plugins';
const TAB_LICENSE = 'license';
const tabs = [TAB_GENERAL, TAB_THEME, TAB_INTERFACE, TAB_PROXY, TAB_PLUGINS, TAB_LICENSE] as const;
const tabs = [
TAB_GENERAL,
TAB_THEME,
TAB_INTERFACE,
TAB_CERTIFICATES,
TAB_PROXY,
TAB_PLUGINS,
TAB_LICENSE,
] as const;
export type SettingsTab = (typeof tabs)[number];
export default function Settings({ hide }: Props) {
const { tab: tabFromQuery } = useSearch({ from: '/workspaces/$workspaceId/settings' });
const [tab, setTab] = useState<string | undefined>(tabFromQuery);
const settings = useAtomValue(settingsAtom);
const plugins = useAtomValue(pluginsAtom);
const licenseCheck = useLicense();
// Close settings window on escape
// TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window
@@ -79,6 +95,16 @@ export default function Settings({ hide }: Props) {
value,
label: capitalize(value),
hidden: !appInfo.featureLicense && value === TAB_LICENSE,
rightSlot:
value === TAB_CERTIFICATES ? (
<CountBadge count={settings.clientCertificates.length} />
) : value === TAB_PLUGINS ? (
<CountBadge count={plugins.length} />
) : value === TAB_PROXY && settings.proxy?.type === 'enabled' ? (
<CountBadge count />
) : value === TAB_LICENSE && licenseCheck.check.data?.status === 'personal_use' ? (
<CountBadge count color="notice" />
) : null,
}),
)}
>
@@ -97,6 +123,9 @@ export default function Settings({ hide }: Props) {
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 !py-4">
<SettingsProxy />
</TabContent>
<TabContent value={TAB_CERTIFICATES} className="overflow-y-auto h-full px-6 !py-4">
<SettingsCertificates />
</TabContent>
<TabContent value={TAB_LICENSE} className="overflow-y-auto h-full px-6 !py-4">
<SettingsLicense />
</TabContent>

View File

@@ -0,0 +1,247 @@
import type { ClientCertificate } from '@yaakapp-internal/models';
import { patchModel, settingsAtom } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import { showConfirmDelete } from '../../lib/confirm';
import { Button } from '../core/Button';
import { Checkbox } from '../core/Checkbox';
import { DetailsBanner } from '../core/DetailsBanner';
import { Heading } from '../core/Heading';
import { IconButton } from '../core/IconButton';
import { InlineCode } from '../core/InlineCode';
import { PlainInput } from '../core/PlainInput';
import { Separator } from '../core/Separator';
import { HStack, VStack } from '../core/Stacks';
import { SelectFile } from '../SelectFile';
function createEmptyCertificate(): ClientCertificate {
return {
host: '',
port: null,
crtFile: null,
keyFile: null,
pfxFile: null,
passphrase: null,
enabled: true,
};
}
interface CertificateEditorProps {
certificate: ClientCertificate;
index: number;
onUpdate: (index: number, cert: ClientCertificate) => void;
onRemove: (index: number) => void;
}
function CertificateEditor({ certificate, index, onUpdate, onRemove }: CertificateEditorProps) {
const updateField = <K extends keyof ClientCertificate>(
field: K,
value: ClientCertificate[K],
) => {
onUpdate(index, { ...certificate, [field]: value });
};
const hasPfx = Boolean(certificate.pfxFile && certificate.pfxFile.length > 0);
const hasCrtKey = Boolean(
(certificate.crtFile && certificate.crtFile.length > 0) ||
(certificate.keyFile && certificate.keyFile.length > 0),
);
// Determine certificate type for display
const certType = hasPfx ? 'PFX' : hasCrtKey ? 'CERT' : null;
return (
<DetailsBanner
summary={
<HStack alignItems="center" justifyContent="between" space={2} className="w-full">
<HStack space={1.5}>
<Checkbox
className="ml-1"
checked={certificate.enabled ?? true}
title={certificate.enabled ? 'Disable certificate' : 'Enable certificate'}
hideLabel
onChange={(enabled) => updateField('enabled', enabled)}
/>
<InlineCode className={classNames(!certificate.host && 'border-danger')}>
{certificate.host || <>&nbsp;</>}
{certificate.port != null && `:${certificate.port}`}
</InlineCode>
{certType && <InlineCode>{certType}</InlineCode>}
</HStack>
<IconButton
icon="trash"
size="sm"
title="Remove certificate"
className="text-text-subtlest -mr-2"
onClick={() => onRemove(index)}
/>
</HStack>
}
>
<VStack space={3} className="mt-2">
<HStack space={2} alignItems="end">
<PlainInput
leftSlot={
<div className="bg-surface-highlight flex items-center text-editor font-mono px-2 text-text-subtle mr-1">
https://
</div>
}
validate={(value) => {
if (!value) return false;
if (!/^[a-zA-Z0-9_.-]+$/.test(value)) return false;
return true;
}}
label="Host"
placeholder="example.com"
size="sm"
required
defaultValue={certificate.host}
onChange={(host) => updateField('host', host)}
/>
<PlainInput
label="Port"
hideLabel
validate={(value) => {
if (!value) return true;
if (Number.isNaN(parseInt(value, 10))) return false;
return true;
}}
placeholder="443"
leftSlot={
<div className="bg-surface-highlight flex items-center text-editor font-mono px-2 text-text-subtle mr-1">
:
</div>
}
size="sm"
className="w-24"
defaultValue={certificate.port?.toString() ?? ''}
onChange={(port) => updateField('port', port ? parseInt(port, 10) : null)}
/>
</HStack>
<Separator className="my-3" />
<VStack space={2}>
<SelectFile
label="CRT File"
noun="Cert"
filePath={certificate.crtFile ?? null}
size="sm"
disabled={hasPfx}
onChange={({ filePath }) => updateField('crtFile', filePath)}
/>
<SelectFile
label="KEY File"
noun="Key"
filePath={certificate.keyFile ?? null}
size="sm"
disabled={hasPfx}
onChange={({ filePath }) => updateField('keyFile', filePath)}
/>
</VStack>
<Separator className="my-3" />
<SelectFile
label="PFX File"
noun="Key"
filePath={certificate.pfxFile ?? null}
size="sm"
disabled={hasCrtKey}
onChange={({ filePath }) => updateField('pfxFile', filePath)}
/>
<PlainInput
label="Passphrase"
size="sm"
type="password"
defaultValue={certificate.passphrase ?? ''}
onChange={(passphrase) => updateField('passphrase', passphrase || null)}
/>
</VStack>
</DetailsBanner>
);
}
export function SettingsCertificates() {
const settings = useAtomValue(settingsAtom);
const certificates = settings.clientCertificates ?? [];
const updateCertificates = async (newCertificates: ClientCertificate[]) => {
await patchModel(settings, { clientCertificates: newCertificates });
};
const handleAdd = async () => {
const newCert = createEmptyCertificate();
await updateCertificates([...certificates, newCert]);
};
const handleUpdate = async (index: number, cert: ClientCertificate) => {
const newCertificates = [...certificates];
newCertificates[index] = cert;
await updateCertificates(newCertificates);
};
const handleRemove = async (index: number) => {
const cert = certificates[index];
if (cert == null) return;
const host = cert.host || 'this certificate';
const port = cert.port != null ? `:${cert.port}` : '';
const confirmed = await showConfirmDelete({
id: 'confirm-remove-certificate',
title: 'Delete Certificate',
description: (
<>
Permanently delete certificate for{' '}
<InlineCode>
{host}
{port}
</InlineCode>
?
</>
),
});
if (!confirmed) return;
const newCertificates = certificates.filter((_, i) => i !== index);
await updateCertificates(newCertificates);
};
return (
<VStack space={3}>
<div className="mb-3">
<HStack justifyContent="between" alignItems="start">
<div>
<Heading>Client Certificates</Heading>
<p className="text-text-subtle">
Add and manage TLS certificates on a per domain basis
</p>
</div>
<Button variant="border" size="sm" color="secondary" onClick={handleAdd}>
Add Certificate
</Button>
</HStack>
</div>
{certificates.length > 0 && (
<VStack space={3}>
{certificates.map((cert, index) => (
<CertificateEditor
// biome-ignore lint/suspicious/noArrayIndexKey: Index is fine here
key={index}
certificate={cert}
index={index}
onUpdate={handleUpdate}
onRemove={handleRemove}
/>
))}
</VStack>
)}
</VStack>
);
}

View File

@@ -26,6 +26,10 @@ export function SettingsGeneral() {
return (
<VStack space={1.5} className="mb-4">
<div className="mb-4">
<Heading>General</Heading>
<p className="text-text-subtle">Configure general settings for update behavior and more.</p>
</div>
<CargoFeature feature="updater">
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-1">
<Select

View File

@@ -13,6 +13,7 @@ import { invokeCmd } from '../../lib/tauri';
import { CargoFeature } from '../CargoFeature';
import { Button } from '../core/Button';
import { Checkbox } from '../core/Checkbox';
import { Heading } from '../core/Heading';
import { Icon } from '../core/Icon';
import { Link } from '../core/Link';
import { Select } from '../core/Select';
@@ -42,6 +43,10 @@ export function SettingsInterface() {
return (
<VStack space={3} className="mb-4">
<div className="mb-3">
<Heading>Interface</Heading>
<p className="text-text-subtle">Tweak settings related to the user interface.</p>
</div>
<Select
name="switchWorkspaceBehavior"
label="Open workspace behavior"

View File

@@ -2,6 +2,7 @@ import { patchModel, settingsAtom } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import { Checkbox } from '../core/Checkbox';
import { Heading } from '../core/Heading';
import { InlineCode } from '../core/InlineCode';
import { PlainInput } from '../core/PlainInput';
import { Select } from '../core/Select';
@@ -13,6 +14,13 @@ export function SettingsProxy() {
return (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Proxy</Heading>
<p className="text-text-subtle">
Configure a proxy server for HTTP requests. Useful for corporate firewalls, debugging
traffic, or routing through specific infrastructure.
</p>
</div>
<Select
name="proxy"
label="Proxy"

View File

@@ -5,9 +5,11 @@ import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace';
import { useResolvedAppearance } from '../../hooks/useResolvedAppearance';
import { useResolvedTheme } from '../../hooks/useResolvedTheme';
import type { ButtonProps } from '../core/Button';
import { Heading } from '../core/Heading';
import type { IconProps } from '../core/Icon';
import { Icon } from '../core/Icon';
import { IconButton } from '../core/IconButton';
import { Link } from '../core/Link';
import type { SelectProps } from '../core/Select';
import { Select } from '../core/Select';
import { HStack, VStack } from '../core/Stacks';
@@ -69,6 +71,15 @@ export function SettingsTheme() {
return (
<VStack space={3} className="mb-4">
<div className="mb-3">
<Heading>Theme</Heading>
<p className="text-text-subtle">
Make Yaak your own by selecting a theme, or{' '}
<Link href="https://feedback.yaak.app/help/articles/6911763-plugins-quick-start">
Create Your Own
</Link>
</p>
</div>
<Select
name="appearance"
label="Appearance"

View File

@@ -1,11 +1,13 @@
import type { Color } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
interface Props {
count: number | true;
className?: string;
color?: Color;
}
export function CountBadge({ count, className }: Props) {
export function CountBadge({ count, className, color }: Props) {
if (count === 0) return null;
return (
<div
@@ -13,10 +15,21 @@ export function CountBadge({ count, className }: Props) {
className={classNames(
className,
'flex items-center',
'opacity-70 border border-border-subtle text-4xs rounded mb-0.5 px-1 ml-1 h-4 font-mono',
'opacity-70 border text-4xs rounded mb-0.5 px-1 ml-1 h-4 font-mono',
color == null && 'border-border-subtle',
color === 'primary' && 'text-primary',
color === 'secondary' && 'text-secondary',
color === 'success' && 'text-success',
color === 'notice' && 'text-notice',
color === 'warning' && 'text-warning',
color === 'danger' && 'text-danger',
)}
>
{count === true ? <div aria-hidden className="rounded-full h-1 w-1 bg-text-subtle" /> : count}
{count === true ? (
<div aria-hidden className="rounded-full h-1 w-1 bg-[currentColor]" />
) : (
count
)}
</div>
);
}

View File

@@ -24,7 +24,7 @@ export function DetailsBanner({ className, color, summary, children, ...extraPro
/>
{summary}
</summary>
<div className="mt-1.5">{children}</div>
<div className="mt-1.5 pb-2">{children}</div>
</details>
</Banner>
);

View File

@@ -162,6 +162,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
'x-theme-input',
'relative w-full rounded-md text',
'border',
'overflow-hidden',
focused ? 'border-border-focus' : 'border-border-subtle',
hasChanged && 'has-[:invalid]:border-danger', // For built-in HTML validation
size === 'md' && 'min-h-md',