From 306e6f358a8e88a9023787893e4626f770a45bb8 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 13 Jan 2026 08:42:22 -0800 Subject: [PATCH] feat: Add DNS timings and resolution overrides (#360) Co-authored-by: Claude Opus 4.5 --- crates-cli/yaak-cli/src/main.rs | 73 ++----- crates-tauri/yaak-app/src/commands.rs | 9 +- crates-tauri/yaak-app/src/grpc.rs | 4 +- crates-tauri/yaak-app/src/history.rs | 2 +- crates-tauri/yaak-app/src/http_request.rs | 54 ++++-- crates-tauri/yaak-app/src/import.rs | 4 +- crates-tauri/yaak-app/src/notifications.rs | 4 +- crates-tauri/yaak-app/src/plugin_events.rs | 27 ++- crates-tauri/yaak-app/src/updates.rs | 2 +- crates-tauri/yaak-app/src/uri_scheme.rs | 7 +- crates-tauri/yaak-app/src/window.rs | 2 +- crates-tauri/yaak-app/src/ws_ext.rs | 25 +-- crates-tauri/yaak-license/src/license.rs | 2 +- crates/yaak-git/bindings/gen_models.ts | 4 +- crates/yaak-http/src/client.rs | 20 +- crates/yaak-http/src/dns.rs | 154 +++++++++++++-- crates/yaak-http/src/manager.rs | 27 ++- crates/yaak-http/src/sender.rs | 22 +++ crates/yaak-models/bindings/gen_models.ts | 8 +- .../migrations/20260111000000_dns-timing.sql | 2 + .../20260112000000_dns-overrides.sql | 2 + crates/yaak-models/src/models.rs | 30 +++ crates/yaak-plugins/bindings/gen_models.ts | 8 +- crates/yaak-plugins/src/api.rs | 5 +- .../src/native_template_functions.rs | 16 +- crates/yaak-plugins/src/template_callback.rs | 9 +- crates/yaak-sync/bindings/gen_models.ts | 4 +- crates/yaak-sync/src/sync.rs | 6 +- crates/yaak-ws/src/manager.rs | 2 +- .../src/bindings/gen_models.ts | 4 +- src-web/components/DnsOverridesEditor.tsx | 181 ++++++++++++++++++ src-web/components/HttpResponseTimeline.tsx | 43 +++++ .../components/WorkspaceSettingsDialog.tsx | 7 + .../core/HttpResponseDurationTag.tsx | 3 +- src-web/components/core/Icon.tsx | 2 + 35 files changed, 625 insertions(+), 149 deletions(-) create mode 100644 crates/yaak-models/migrations/20260111000000_dns-timing.sql create mode 100644 crates/yaak-models/migrations/20260112000000_dns-overrides.sql create mode 100644 src-web/components/DnsOverridesEditor.tsx diff --git a/crates-cli/yaak-cli/src/main.rs b/crates-cli/yaak-cli/src/main.rs index 42fb6482..ab8cd9f1 100644 --- a/crates-cli/yaak-cli/src/main.rs +++ b/crates-cli/yaak-cli/src/main.rs @@ -15,7 +15,7 @@ use yaak_models::util::UpdateSource; use yaak_plugins::events::{PluginContext, RenderPurpose}; use yaak_plugins::manager::PluginManager; use yaak_plugins::template_callback::PluginTemplateCallback; -use yaak_templates::{parse_and_render, render_json_value_raw, RenderOptions}; +use yaak_templates::{RenderOptions, parse_and_render, render_json_value_raw}; #[derive(Parser)] #[command(name = "yaakcli")] @@ -149,14 +149,7 @@ async fn render_http_request( // Apply path placeholders (e.g., /users/:id -> /users/123) let (url, url_parameters) = apply_path_placeholders(&url, &url_parameters); - Ok(HttpRequest { - url, - url_parameters, - headers, - body, - authentication, - ..r.to_owned() - }) + Ok(HttpRequest { url, url_parameters, headers, body, authentication, ..r.to_owned() }) } #[tokio::main] @@ -169,16 +162,10 @@ async fn main() { } // Use the same app_id for both data directory and keyring - let app_id = if cfg!(debug_assertions) { - "app.yaak.desktop.dev" - } else { - "app.yaak.desktop" - }; + let app_id = if cfg!(debug_assertions) { "app.yaak.desktop.dev" } else { "app.yaak.desktop" }; let data_dir = cli.data_dir.unwrap_or_else(|| { - dirs::data_dir() - .expect("Could not determine data directory") - .join(app_id) + dirs::data_dir().expect("Could not determine data directory").join(app_id) }); let db_path = data_dir.join("db.sqlite"); @@ -191,9 +178,7 @@ async fn main() { // Initialize encryption manager for secure() template function // Use the same app_id as the Tauri app for keyring access - let encryption_manager = Arc::new( - EncryptionManager::new(query_manager.clone(), app_id), - ); + let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id)); // Initialize plugin manager for template functions let vendored_plugin_dir = data_dir.join("vendored-plugins"); @@ -203,9 +188,8 @@ async fn main() { let node_bin_path = PathBuf::from("node"); // Find the plugin runtime - check YAAK_PLUGIN_RUNTIME env var, then fallback to development path - let plugin_runtime_main = std::env::var("YAAK_PLUGIN_RUNTIME") - .map(PathBuf::from) - .unwrap_or_else(|_| { + let plugin_runtime_main = + std::env::var("YAAK_PLUGIN_RUNTIME").map(PathBuf::from).unwrap_or_else(|_| { // Development fallback: look relative to crate root PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs") @@ -226,14 +210,10 @@ async fn main() { // Initialize plugins from database let plugins = db.list_plugins().unwrap_or_default(); if !plugins.is_empty() { - let errors = plugin_manager - .initialize_all_plugins(plugins, &PluginContext::new_empty()) - .await; + let errors = + plugin_manager.initialize_all_plugins(plugins, &PluginContext::new_empty()).await; for (plugin_dir, error_msg) in errors { - eprintln!( - "Warning: Failed to initialize plugin '{}': {}", - plugin_dir, error_msg - ); + eprintln!("Warning: Failed to initialize plugin '{}': {}", plugin_dir, error_msg); } } @@ -249,9 +229,7 @@ async fn main() { } } Commands::Requests { workspace_id } => { - let requests = db - .list_http_requests(&workspace_id) - .expect("Failed to list requests"); + let requests = db.list_http_requests(&workspace_id).expect("Failed to list requests"); if requests.is_empty() { println!("No requests found in workspace {}", workspace_id); } else { @@ -261,9 +239,7 @@ async fn main() { } } Commands::Send { request_id } => { - let request = db - .get_http_request(&request_id) - .expect("Failed to get request"); + let request = db.get_http_request(&request_id).expect("Failed to get request"); // Resolve environment chain for variable substitution let environment_chain = db @@ -318,18 +294,13 @@ async fn main() { })) } else { // Drain events silently - tokio::spawn(async move { - while event_rx.recv().await.is_some() {} - }); + tokio::spawn(async move { while event_rx.recv().await.is_some() {} }); None }; // Send the request let sender = ReqwestSender::new().expect("Failed to create HTTP client"); - let response = sender - .send(sendable, event_tx) - .await - .expect("Failed to send request"); + let response = sender.send(sendable, event_tx).await.expect("Failed to send request"); // Wait for event handler to finish if let Some(handle) = verbose_handle { @@ -383,18 +354,13 @@ async fn main() { } })) } else { - tokio::spawn(async move { - while event_rx.recv().await.is_some() {} - }); + tokio::spawn(async move { while event_rx.recv().await.is_some() {} }); None }; // Send the request let sender = ReqwestSender::new().expect("Failed to create HTTP client"); - let response = sender - .send(sendable, event_tx) - .await - .expect("Failed to send request"); + let response = sender.send(sendable, event_tx).await.expect("Failed to send request"); if let Some(handle) = verbose_handle { let _ = handle.await; @@ -421,12 +387,7 @@ async fn main() { let (body, _stats) = response.text().await.expect("Failed to read response body"); println!("{}", body); } - Commands::Create { - workspace_id, - name, - method, - url, - } => { + Commands::Create { workspace_id, name, method, url } => { let request = HttpRequest { workspace_id, name, diff --git a/crates-tauri/yaak-app/src/commands.rs b/crates-tauri/yaak-app/src/commands.rs index 6103e8ae..db3fa878 100644 --- a/crates-tauri/yaak-app/src/commands.rs +++ b/crates-tauri/yaak-app/src/commands.rs @@ -1,5 +1,5 @@ -use crate::error::Result; use crate::PluginContextExt; +use crate::error::Result; use std::sync::Arc; use tauri::{AppHandle, Manager, Runtime, State, WebviewWindow, command}; use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; @@ -54,7 +54,12 @@ pub(crate) async fn cmd_secure_template( let plugin_manager = Arc::new((*app_handle.state::()).clone()); let encryption_manager = Arc::new((*app_handle.state::()).clone()); let plugin_context = window.plugin_context(); - Ok(encrypt_secure_template_function(plugin_manager, encryption_manager, &plugin_context, template)?) + Ok(encrypt_secure_template_function( + plugin_manager, + encryption_manager, + &plugin_context, + template, + )?) } #[command] diff --git a/crates-tauri/yaak-app/src/grpc.rs b/crates-tauri/yaak-app/src/grpc.rs index 45cce031..379be919 100644 --- a/crates-tauri/yaak-app/src/grpc.rs +++ b/crates-tauri/yaak-app/src/grpc.rs @@ -1,12 +1,12 @@ use std::collections::BTreeMap; -use crate::error::Result; use crate::PluginContextExt; +use crate::error::Result; +use crate::models_ext::QueryManagerExt; use KeyAndValueRef::{Ascii, Binary}; use tauri::{Manager, Runtime, WebviewWindow}; use yaak_grpc::{KeyAndValueRef, MetadataMap}; use yaak_models::models::GrpcRequest; -use crate::models_ext::QueryManagerExt; use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader}; use yaak_plugins::manager::PluginManager; diff --git a/crates-tauri/yaak-app/src/history.rs b/crates-tauri/yaak-app/src/history.rs index aaa3e1cb..d2624a34 100644 --- a/crates-tauri/yaak-app/src/history.rs +++ b/crates-tauri/yaak-app/src/history.rs @@ -1,8 +1,8 @@ +use crate::models_ext::QueryManagerExt; use chrono::{NaiveDateTime, Utc}; use log::debug; use std::sync::OnceLock; use tauri::{AppHandle, Runtime}; -use crate::models_ext::QueryManagerExt; use yaak_models::util::UpdateSource; const NAMESPACE: &str = "analytics"; diff --git a/crates-tauri/yaak-app/src/http_request.rs b/crates-tauri/yaak-app/src/http_request.rs index e7996a0c..109856ad 100644 --- a/crates-tauri/yaak-app/src/http_request.rs +++ b/crates-tauri/yaak-app/src/http_request.rs @@ -1,9 +1,13 @@ +use crate::PluginContextExt; use crate::error::Error::GenericError; use crate::error::Result; +use crate::models_ext::BlobManagerExt; +use crate::models_ext::QueryManagerExt; use crate::render::render_http_request; use log::{debug, warn}; use std::pin::Pin; use std::sync::Arc; +use std::sync::atomic::{AtomicI32, Ordering}; use std::time::{Duration, Instant}; use tauri::{AppHandle, Manager, Runtime, WebviewWindow}; use tokio::fs::{File, create_dir_all}; @@ -15,22 +19,19 @@ use yaak_http::client::{ HttpConnectionOptions, HttpConnectionProxySetting, HttpConnectionProxySettingAuth, }; use yaak_http::cookies::CookieStore; -use yaak_http::manager::HttpConnectionManager; +use yaak_http::manager::{CachedClient, HttpConnectionManager}; use yaak_http::sender::ReqwestSender; use yaak_http::tee_reader::TeeReader; use yaak_http::transaction::HttpTransaction; use yaak_http::types::{ SendableBody, SendableHttpRequest, SendableHttpRequestOptions, append_query_params, }; -use crate::models_ext::BlobManagerExt; use yaak_models::blob_manager::BodyChunk; use yaak_models::models::{ CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseHeader, HttpResponseState, ProxySetting, ProxySettingAuth, }; -use crate::models_ext::QueryManagerExt; use yaak_models::util::UpdateSource; -use crate::PluginContextExt; use yaak_plugins::events::{ CallHttpAuthenticationRequest, HttpHeader, PluginContext, RenderPurpose, }; @@ -173,7 +174,12 @@ async fn send_http_request_inner( let environment_id = environment.map(|e| e.id); let workspace = window.db().get_workspace(workspace_id)?; let (resolved, auth_context_id) = resolve_http_request(window, unrendered_request)?; - let cb = PluginTemplateCallback::new(plugin_manager.clone(), encryption_manager.clone(), &plugin_context, RenderPurpose::Send); + let cb = PluginTemplateCallback::new( + plugin_manager.clone(), + encryption_manager.clone(), + &plugin_context, + RenderPurpose::Send, + ); let env_chain = window.db().resolve_environments(&workspace.id, folder_id, environment_id.as_deref())?; let request = render_http_request(&resolved, env_chain, &cb, &RenderOptions::throw()).await?; @@ -228,12 +234,13 @@ async fn send_http_request_inner( None => None, }; - let client = connection_manager + let cached_client = connection_manager .get_client(&HttpConnectionOptions { id: plugin_context.id.clone(), validate_certificates: workspace.setting_validate_certificates, proxy: proxy_setting, client_certificate, + dns_overrides: workspace.setting_dns_overrides.clone(), }) .await?; @@ -250,7 +257,7 @@ async fn send_http_request_inner( let cookie_store = maybe_cookie_store.as_ref().map(|(cs, _)| cs.clone()); let result = execute_transaction( - client, + cached_client, sendable_request, response_ctx, cancelled_rx.clone(), @@ -310,7 +317,7 @@ pub fn resolve_http_request( } async fn execute_transaction( - client: reqwest::Client, + cached_client: CachedClient, mut sendable_request: SendableHttpRequest, response_ctx: &mut ResponseContext, mut cancelled_rx: Receiver, @@ -321,7 +328,10 @@ async fn execute_transaction( let workspace_id = response_ctx.response().workspace_id.clone(); let is_persisted = response_ctx.is_persisted(); - let sender = ReqwestSender::with_client(client); + // Keep a reference to the resolver for DNS timing events + let resolver = cached_client.resolver.clone(); + + let sender = ReqwestSender::with_client(cached_client.client); let transaction = match cookie_store { Some(cs) => HttpTransaction::with_cookie_store(sender, cs), None => HttpTransaction::new(sender), @@ -346,21 +356,39 @@ async fn execute_transaction( let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::(100); + // Set the event sender on the DNS resolver so it can emit DNS timing events + resolver.set_event_sender(Some(event_tx.clone())).await; + + // Shared state to capture DNS timing from the event processing task + let dns_elapsed = Arc::new(AtomicI32::new(0)); + // Write events to DB in a task (only for persisted responses) if is_persisted { let response_id = response_id.clone(); let app_handle = app_handle.clone(); let update_source = response_ctx.update_source.clone(); let workspace_id = workspace_id.clone(); + let dns_elapsed = dns_elapsed.clone(); tokio::spawn(async move { while let Some(event) = event_rx.recv().await { + // Capture DNS timing when we see a DNS event + if let yaak_http::sender::HttpResponseEvent::DnsResolved { duration, .. } = &event { + dns_elapsed.store(*duration as i32, Ordering::SeqCst); + } let db_event = HttpResponseEvent::new(&response_id, &workspace_id, event.into()); let _ = app_handle.db().upsert_http_response_event(&db_event, &update_source); } }); } else { - // For ephemeral responses, just drain the events - tokio::spawn(async move { while event_rx.recv().await.is_some() {} }); + // For ephemeral responses, just drain the events but still capture DNS timing + let dns_elapsed = dns_elapsed.clone(); + tokio::spawn(async move { + while let Some(event) = event_rx.recv().await { + if let yaak_http::sender::HttpResponseEvent::DnsResolved { duration, .. } = &event { + dns_elapsed.store(*duration as i32, Ordering::SeqCst); + } + } + }); }; // Capture request body as it's sent (only for persisted responses) @@ -528,10 +556,14 @@ async fn execute_transaction( // Final update with closed state and accurate byte count response_ctx.update(|r| { r.elapsed = start.elapsed().as_millis() as i32; + r.elapsed_dns = dns_elapsed.load(Ordering::SeqCst); r.content_length = Some(written_bytes as i32); r.state = HttpResponseState::Closed; })?; + // Clear the event sender from the resolver since this request is done + resolver.set_event_sender(None).await; + Ok((response_ctx.response().clone(), maybe_blob_write_handle)) } diff --git a/crates-tauri/yaak-app/src/import.rs b/crates-tauri/yaak-app/src/import.rs index c47036cc..e8b960ef 100644 --- a/crates-tauri/yaak-app/src/import.rs +++ b/crates-tauri/yaak-app/src/import.rs @@ -1,17 +1,17 @@ +use crate::PluginContextExt; use crate::error::Result; use crate::models_ext::QueryManagerExt; -use crate::PluginContextExt; use log::info; use std::collections::BTreeMap; use std::fs::read_to_string; use tauri::{Manager, Runtime, WebviewWindow}; -use yaak_tauri_utils::window::WorkspaceWindowTrait; use yaak_core::WorkspaceContext; use yaak_models::models::{ Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace, }; use yaak_models::util::{BatchUpsertResult, UpdateSource, maybe_gen_id, maybe_gen_id_opt}; use yaak_plugins::manager::PluginManager; +use yaak_tauri_utils::window::WorkspaceWindowTrait; pub(crate) async fn import_data( window: &WebviewWindow, diff --git a/crates-tauri/yaak-app/src/notifications.rs b/crates-tauri/yaak-app/src/notifications.rs index 674313d7..9354e5f3 100644 --- a/crates-tauri/yaak-app/src/notifications.rs +++ b/crates-tauri/yaak-app/src/notifications.rs @@ -1,5 +1,6 @@ use crate::error::Result; use crate::history::get_or_upsert_launch_info; +use crate::models_ext::QueryManagerExt; use chrono::{DateTime, Utc}; use log::{debug, info}; use reqwest::Method; @@ -8,9 +9,8 @@ use std::time::Instant; use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow}; use ts_rs::TS; use yaak_common::platform::get_os_str; -use yaak_tauri_utils::api_client::yaak_api_client; -use crate::models_ext::QueryManagerExt; use yaak_models::util::UpdateSource; +use yaak_tauri_utils::api_client::yaak_api_client; // Check for updates every hour const MAX_UPDATE_CHECK_SECONDS: u64 = 60 * 60; diff --git a/crates-tauri/yaak-app/src/plugin_events.rs b/crates-tauri/yaak-app/src/plugin_events.rs index 0ac78a94..b28ec501 100644 --- a/crates-tauri/yaak-app/src/plugin_events.rs +++ b/crates-tauri/yaak-app/src/plugin_events.rs @@ -1,5 +1,7 @@ use crate::error::Result; use crate::http_request::send_http_request_with_context; +use crate::models_ext::BlobManagerExt; +use crate::models_ext::QueryManagerExt; use crate::render::{render_grpc_request, render_http_request, render_json_value}; use crate::window::{CreateWindowConfig, create_window}; use crate::{ @@ -14,11 +16,8 @@ use tauri::{AppHandle, Emitter, Manager, Runtime}; use tauri_plugin_clipboard_manager::ClipboardExt; use tauri_plugin_opener::OpenerExt; use yaak_crypto::manager::EncryptionManager; -use yaak_tauri_utils::window::WorkspaceWindowTrait; -use crate::models_ext::BlobManagerExt; use yaak_models::models::{AnyModel, HttpResponse, Plugin}; use yaak_models::queries::any_request::AnyRequest; -use crate::models_ext::QueryManagerExt; use yaak_models::util::UpdateSource; use yaak_plugins::error::Error::PluginErr; use yaak_plugins::events::{ @@ -32,6 +31,7 @@ use yaak_plugins::events::{ use yaak_plugins::manager::PluginManager; use yaak_plugins::plugin_handle::PluginHandle; use yaak_plugins::template_callback::PluginTemplateCallback; +use yaak_tauri_utils::window::WorkspaceWindowTrait; use yaak_templates::{RenderErrorBehavior, RenderOptions}; pub(crate) async fn handle_plugin_event( @@ -170,7 +170,12 @@ pub(crate) async fn handle_plugin_event( )?; let plugin_manager = Arc::new((*app_handle.state::()).clone()); let encryption_manager = Arc::new((*app_handle.state::()).clone()); - let cb = PluginTemplateCallback::new(plugin_manager, encryption_manager, &plugin_context, req.purpose); + let cb = PluginTemplateCallback::new( + plugin_manager, + encryption_manager, + &plugin_context, + req.purpose, + ); let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw }; let grpc_request = render_grpc_request(&req.grpc_request, environment_chain, &cb, &opt).await?; @@ -191,7 +196,12 @@ pub(crate) async fn handle_plugin_event( )?; let plugin_manager = Arc::new((*app_handle.state::()).clone()); let encryption_manager = Arc::new((*app_handle.state::()).clone()); - let cb = PluginTemplateCallback::new(plugin_manager, encryption_manager, &plugin_context, req.purpose); + let cb = PluginTemplateCallback::new( + plugin_manager, + encryption_manager, + &plugin_context, + req.purpose, + ); let opt = &RenderOptions { error_behavior: RenderErrorBehavior::Throw }; let http_request = render_http_request(&req.http_request, environment_chain, &cb, &opt).await?; @@ -222,7 +232,12 @@ pub(crate) async fn handle_plugin_event( )?; let plugin_manager = Arc::new((*app_handle.state::()).clone()); let encryption_manager = Arc::new((*app_handle.state::()).clone()); - let cb = PluginTemplateCallback::new(plugin_manager, encryption_manager, &plugin_context, req.purpose); + let cb = PluginTemplateCallback::new( + plugin_manager, + encryption_manager, + &plugin_context, + req.purpose, + ); let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw }; let data = render_json_value(req.data, environment_chain, &cb, &opt).await?; Ok(Some(InternalEventPayload::TemplateRenderResponse(TemplateRenderResponse { data }))) diff --git a/crates-tauri/yaak-app/src/updates.rs b/crates-tauri/yaak-app/src/updates.rs index 3307b282..b8f64a5a 100644 --- a/crates-tauri/yaak-app/src/updates.rs +++ b/crates-tauri/yaak-app/src/updates.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use std::time::{Duration, Instant}; use crate::error::Result; +use crate::models_ext::QueryManagerExt; use log::{debug, error, info, warn}; use serde::{Deserialize, Serialize}; use tauri::{Emitter, Listener, Manager, Runtime, WebviewWindow}; @@ -11,7 +12,6 @@ use tauri_plugin_updater::{Update, UpdaterExt}; use tokio::task::block_in_place; use tokio::time::sleep; use ts_rs::TS; -use crate::models_ext::QueryManagerExt; use yaak_models::util::generate_id; use yaak_plugins::manager::PluginManager; diff --git a/crates-tauri/yaak-app/src/uri_scheme.rs b/crates-tauri/yaak-app/src/uri_scheme.rs index ed6f2bae..52119241 100644 --- a/crates-tauri/yaak-app/src/uri_scheme.rs +++ b/crates-tauri/yaak-app/src/uri_scheme.rs @@ -1,18 +1,18 @@ +use crate::PluginContextExt; use crate::error::Result; use crate::import::import_data; use crate::models_ext::QueryManagerExt; -use crate::PluginContextExt; use log::{info, warn}; use std::collections::HashMap; use std::fs; use std::sync::Arc; use tauri::{AppHandle, Emitter, Manager, Runtime, Url}; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; -use yaak_tauri_utils::api_client::yaak_api_client; use yaak_models::util::generate_id; use yaak_plugins::events::{Color, ShowToastRequest}; use yaak_plugins::install::download_and_install; use yaak_plugins::manager::PluginManager; +use yaak_tauri_utils::api_client::yaak_api_client; pub(crate) async fn handle_deep_link( app_handle: &AppHandle, @@ -55,7 +55,8 @@ pub(crate) async fn handle_deep_link( &plugin_context, name, version, - ).await?; + ) + .await?; app_handle.emit( "show_toast", ShowToastRequest { diff --git a/crates-tauri/yaak-app/src/window.rs b/crates-tauri/yaak-app/src/window.rs index ddc06fea..d5aa6ad0 100644 --- a/crates-tauri/yaak-app/src/window.rs +++ b/crates-tauri/yaak-app/src/window.rs @@ -1,4 +1,5 @@ use crate::error::Result; +use crate::models_ext::QueryManagerExt; use crate::window_menu::app_menu; use log::{info, warn}; use rand::random; @@ -8,7 +9,6 @@ use tauri::{ }; use tauri_plugin_opener::OpenerExt; use tokio::sync::mpsc; -use crate::models_ext::QueryManagerExt; const DEFAULT_WINDOW_WIDTH: f64 = 1100.0; const DEFAULT_WINDOW_HEIGHT: f64 = 600.0; diff --git a/crates-tauri/yaak-app/src/ws_ext.rs b/crates-tauri/yaak-app/src/ws_ext.rs index ab1f7f95..8bba2b3c 100644 --- a/crates-tauri/yaak-app/src/ws_ext.rs +++ b/crates-tauri/yaak-app/src/ws_ext.rs @@ -1,9 +1,9 @@ //! WebSocket Tauri command wrappers //! These wrap the core yaak-ws functionality for Tauri IPC. +use crate::PluginContextExt; use crate::error::Result; use crate::models_ext::QueryManagerExt; -use crate::PluginContextExt; use http::HeaderMap; use log::{debug, info, warn}; use std::str::FromStr; @@ -56,9 +56,10 @@ pub async fn cmd_ws_delete_request( app_handle: AppHandle, window: WebviewWindow, ) -> Result { - Ok(app_handle - .db() - .delete_websocket_request_by_id(request_id, &UpdateSource::from_window_label(window.label()))?) + Ok(app_handle.db().delete_websocket_request_by_id( + request_id, + &UpdateSource::from_window_label(window.label()), + )?) } #[command] @@ -67,12 +68,10 @@ pub async fn cmd_ws_delete_connection( app_handle: AppHandle, window: WebviewWindow, ) -> Result { - Ok(app_handle - .db() - .delete_websocket_connection_by_id( - connection_id, - &UpdateSource::from_window_label(window.label()), - )?) + Ok(app_handle.db().delete_websocket_connection_by_id( + connection_id, + &UpdateSource::from_window_label(window.label()), + )?) } #[command] @@ -296,8 +295,10 @@ pub async fn cmd_ws_connect( ) .await?; for header in plugin_result.set_headers.unwrap_or_default() { - match (http::HeaderName::from_str(&header.name), HeaderValue::from_str(&header.value)) - { + match ( + http::HeaderName::from_str(&header.name), + HeaderValue::from_str(&header.value), + ) { (Ok(name), Ok(value)) => { headers.insert(name, value); } diff --git a/crates-tauri/yaak-license/src/license.rs b/crates-tauri/yaak-license/src/license.rs index fa7f97d7..dc6c185e 100644 --- a/crates-tauri/yaak-license/src/license.rs +++ b/crates-tauri/yaak-license/src/license.rs @@ -8,10 +8,10 @@ use std::time::Duration; use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow, is_dev}; use ts_rs::TS; use yaak_common::platform::get_os_str; -use yaak_tauri_utils::api_client::yaak_api_client; use yaak_models::db_context::DbContext; use yaak_models::query_manager::QueryManager; use yaak_models::util::UpdateSource; +use yaak_tauri_utils::api_client::yaak_api_client; /// Extension trait for accessing the QueryManager from Tauri Manager types. /// This is needed temporarily until all crates are refactored to not use Tauri. diff --git a/crates/yaak-git/bindings/gen_models.ts b/crates/yaak-git/bindings/gen_models.ts index 84a0f770..ee83e664 100644 --- a/crates/yaak-git/bindings/gen_models.ts +++ b/crates/yaak-git/bindings/gen_models.ts @@ -1,5 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +export type DnsOverride = { hostname: string, ipv4: Array, ipv6: Array, enabled?: boolean, }; + export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array, color: string | null, sortPriority: number, }; export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, }; @@ -18,4 +20,4 @@ export type SyncModel = { "type": "workspace" } & Workspace | { "type": "environ export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, description: string, headers: Array, message: string, name: string, sortPriority: number, url: string, urlParameters: Array, }; -export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, }; +export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array, }; diff --git a/crates/yaak-http/src/client.rs b/crates/yaak-http/src/client.rs index 16d544a5..26be82c9 100644 --- a/crates/yaak-http/src/client.rs +++ b/crates/yaak-http/src/client.rs @@ -2,6 +2,8 @@ use crate::dns::LocalhostResolver; use crate::error::Result; use log::{debug, info, warn}; use reqwest::{Client, Proxy, redirect}; +use std::sync::Arc; +use yaak_models::models::DnsOverride; use yaak_tls::{ClientCertificateConfig, get_tls_config}; #[derive(Clone)] @@ -28,10 +30,14 @@ pub struct HttpConnectionOptions { pub validate_certificates: bool, pub proxy: HttpConnectionProxySetting, pub client_certificate: Option, + pub dns_overrides: Vec, } impl HttpConnectionOptions { - pub(crate) fn build_client(&self) -> Result { + /// Build a reqwest Client and return it along with the DNS resolver. + /// The resolver is returned separately so it can be configured per-request + /// to emit DNS timing events to the appropriate channel. + pub(crate) fn build_client(&self) -> Result<(Client, Arc)> { let mut client = Client::builder() .connection_verbose(true) .redirect(redirect::Policy::none()) @@ -40,15 +46,19 @@ impl HttpConnectionOptions { .no_brotli() .no_deflate() .referer(false) - .tls_info(true); + .tls_info(true) + // Disable connection pooling to ensure DNS resolution happens on each request + // This is needed so we can emit DNS timing events for each request + .pool_max_idle_per_host(0); // 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()); + // Configure DNS resolver - keep a reference to configure per-request + let resolver = LocalhostResolver::new(self.dns_overrides.clone()); + client = client.dns_resolver(resolver.clone()); // Configure proxy match self.proxy.clone() { @@ -69,7 +79,7 @@ impl HttpConnectionOptions { self.client_certificate.is_some() ); - Ok(client.build()?) + Ok((client.build()?, resolver)) } } diff --git a/crates/yaak-http/src/dns.rs b/crates/yaak-http/src/dns.rs index a41714f9..4747f73a 100644 --- a/crates/yaak-http/src/dns.rs +++ b/crates/yaak-http/src/dns.rs @@ -1,53 +1,185 @@ +use crate::sender::HttpResponseEvent; use hyper_util::client::legacy::connect::dns::{ GaiResolver as HyperGaiResolver, Name as HyperName, }; +use log::info; use reqwest::dns::{Addrs, Name, Resolve, Resolving}; +use std::collections::HashMap; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::str::FromStr; use std::sync::Arc; +use std::time::Instant; +use tokio::sync::{RwLock, mpsc}; use tower_service::Service; +use yaak_models::models::DnsOverride; + +/// Stores resolved addresses for a hostname override +#[derive(Clone)] +pub struct ResolvedOverride { + pub ipv4: Vec, + pub ipv6: Vec, +} #[derive(Clone)] pub struct LocalhostResolver { fallback: HyperGaiResolver, + event_tx: Arc>>>, + overrides: Arc>, } impl LocalhostResolver { - pub fn new() -> Arc { + pub fn new(dns_overrides: Vec) -> Arc { let resolver = HyperGaiResolver::new(); - Arc::new(Self { fallback: resolver }) + + // Pre-parse DNS overrides into a lookup map + let mut overrides = HashMap::new(); + for o in dns_overrides { + if !o.enabled { + continue; + } + let hostname = o.hostname.to_lowercase(); + + let ipv4: Vec = + o.ipv4.iter().filter_map(|s| s.parse::().ok()).collect(); + + let ipv6: Vec = + o.ipv6.iter().filter_map(|s| s.parse::().ok()).collect(); + + // Only add if at least one address is valid + if !ipv4.is_empty() || !ipv6.is_empty() { + overrides.insert(hostname, ResolvedOverride { ipv4, ipv6 }); + } + } + + Arc::new(Self { + fallback: resolver, + event_tx: Arc::new(RwLock::new(None)), + overrides: Arc::new(overrides), + }) + } + + /// Set the event sender for the current request. + /// This should be called before each request to direct DNS events + /// to the appropriate channel. + pub async fn set_event_sender(&self, tx: Option>) { + let mut guard = self.event_tx.write().await; + *guard = tx; } } impl Resolve for LocalhostResolver { fn resolve(&self, name: Name) -> Resolving { let host = name.as_str().to_lowercase(); + let event_tx = self.event_tx.clone(); + let overrides = self.overrides.clone(); + info!("DNS resolve called for: {}", host); + + // Check for DNS override first + if let Some(resolved) = overrides.get(&host) { + log::debug!("DNS override found for: {}", host); + let hostname = host.clone(); + let mut addrs: Vec = Vec::new(); + + // Add IPv4 addresses + for ip in &resolved.ipv4 { + addrs.push(SocketAddr::new(IpAddr::V4(*ip), 0)); + } + + // Add IPv6 addresses + for ip in &resolved.ipv6 { + addrs.push(SocketAddr::new(IpAddr::V6(*ip), 0)); + } + + let addresses: Vec = addrs.iter().map(|a| a.ip().to_string()).collect(); + + return Box::pin(async move { + // Emit DNS event for override + let guard = event_tx.read().await; + if let Some(tx) = guard.as_ref() { + let _ = tx + .send(HttpResponseEvent::DnsResolved { + hostname, + addresses, + duration: 0, + overridden: true, + }) + .await; + } + + Ok::>(Box::new(addrs.into_iter())) + }); + } + + // Check for .localhost suffix let is_localhost = host.ends_with(".localhost"); if is_localhost { + let hostname = host.clone(); // Port 0 is fine; reqwest replaces it with the URL's explicit - // port or the scheme’s default (80/443, etc.). - // (See docs note below.) + // port or the scheme's default (80/443, etc.). let addrs: Vec = vec![ SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0), SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0), ]; + let addresses: Vec = addrs.iter().map(|a| a.ip().to_string()).collect(); + return Box::pin(async move { + // Emit DNS event for localhost resolution + let guard = event_tx.read().await; + if let Some(tx) = guard.as_ref() { + let _ = tx + .send(HttpResponseEvent::DnsResolved { + hostname, + addresses, + duration: 0, + overridden: false, + }) + .await; + } + Ok::>(Box::new(addrs.into_iter())) }); } + // Fall back to system DNS let mut fallback = self.fallback.clone(); let name_str = name.as_str().to_string(); + let hostname = host.clone(); + Box::pin(async move { - match HyperName::from_str(&name_str) { - Ok(n) => fallback - .call(n) - .await - .map(|addrs| Box::new(addrs) as Addrs) - .map_err(|err| Box::new(err) as Box), - Err(e) => Err(Box::new(e) as Box), + let start = Instant::now(); + + let result = match HyperName::from_str(&name_str) { + Ok(n) => fallback.call(n).await, + Err(e) => return Err(Box::new(e) as Box), + }; + + let duration = start.elapsed().as_millis() as u64; + + match result { + Ok(addrs) => { + // Collect addresses for event emission + let addr_vec: Vec = addrs.collect(); + let addresses: Vec = + addr_vec.iter().map(|a| a.ip().to_string()).collect(); + + // Emit DNS event + let guard = event_tx.read().await; + if let Some(tx) = guard.as_ref() { + let _ = tx + .send(HttpResponseEvent::DnsResolved { + hostname, + addresses, + duration, + overridden: false, + }) + .await; + } + + Ok(Box::new(addr_vec.into_iter()) as Addrs) + } + Err(err) => Err(Box::new(err) as Box), } }) } diff --git a/crates/yaak-http/src/manager.rs b/crates/yaak-http/src/manager.rs index 41f845b9..beeca92c 100644 --- a/crates/yaak-http/src/manager.rs +++ b/crates/yaak-http/src/manager.rs @@ -1,4 +1,5 @@ use crate::client::HttpConnectionOptions; +use crate::dns::LocalhostResolver; use crate::error::Result; use log::info; use reqwest::Client; @@ -7,8 +8,15 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::RwLock; +/// A cached HTTP client along with its DNS resolver. +/// The resolver is needed to set the event sender per-request. +pub struct CachedClient { + pub client: Client, + pub resolver: Arc, +} + pub struct HttpConnectionManager { - connections: Arc>>, + connections: Arc>>, ttl: Duration, } @@ -20,21 +28,26 @@ impl HttpConnectionManager { } } - pub async fn get_client(&self, opt: &HttpConnectionOptions) -> Result { + pub async fn get_client(&self, opt: &HttpConnectionOptions) -> Result { 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((cached, last_used)) = connections.get_mut(&id) { info!("Re-using HTTP client {id}"); *last_used = Instant::now(); - return Ok(c.clone()); + return Ok(CachedClient { + client: cached.client.clone(), + resolver: cached.resolver.clone(), + }); } - let c = opt.build_client()?; - connections.insert(id.into(), (c.clone(), Instant::now())); - Ok(c) + let (client, resolver) = opt.build_client()?; + let cached = CachedClient { client: client.clone(), resolver: resolver.clone() }; + connections.insert(id.into(), (cached, Instant::now())); + + Ok(CachedClient { client, resolver }) } } diff --git a/crates/yaak-http/src/sender.rs b/crates/yaak-http/src/sender.rs index 0e62a3d2..376c33b6 100644 --- a/crates/yaak-http/src/sender.rs +++ b/crates/yaak-http/src/sender.rs @@ -45,6 +45,12 @@ pub enum HttpResponseEvent { ChunkReceived { bytes: usize, }, + DnsResolved { + hostname: String, + addresses: Vec, + duration: u64, + overridden: bool, + }, } impl Display for HttpResponseEvent { @@ -67,6 +73,19 @@ impl Display for HttpResponseEvent { HttpResponseEvent::HeaderDown(name, value) => write!(f, "< {}: {}", name, value), HttpResponseEvent::ChunkSent { bytes } => write!(f, "> [{} bytes sent]", bytes), HttpResponseEvent::ChunkReceived { bytes } => write!(f, "< [{} bytes received]", bytes), + HttpResponseEvent::DnsResolved { hostname, addresses, duration, overridden } => { + if *overridden { + write!(f, "* DNS override {} -> {}", hostname, addresses.join(", ")) + } else { + write!( + f, + "* DNS resolved {} to {} ({}ms)", + hostname, + addresses.join(", "), + duration + ) + } + } } } } @@ -93,6 +112,9 @@ impl From for yaak_models::models::HttpResponseEventData { HttpResponseEvent::HeaderDown(name, value) => D::HeaderDown { name, value }, HttpResponseEvent::ChunkSent { bytes } => D::ChunkSent { bytes }, HttpResponseEvent::ChunkReceived { bytes } => D::ChunkReceived { bytes }, + HttpResponseEvent::DnsResolved { hostname, addresses, duration, overridden } => { + D::DnsResolved { hostname, addresses, duration, overridden } + } } } } diff --git a/crates/yaak-models/bindings/gen_models.ts b/crates/yaak-models/bindings/gen_models.ts index b709807b..5b226f6f 100644 --- a/crates/yaak-models/bindings/gen_models.ts +++ b/crates/yaak-models/bindings/gen_models.ts @@ -12,6 +12,8 @@ export type CookieExpires = { "AtUtc": string } | "SessionEnd"; export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array, name: string, }; +export type DnsOverride = { hostname: string, ipv4: Array, ipv6: Array, enabled?: boolean, }; + export type EditorKeymap = "default" | "vim" | "vscode" | "emacs"; export type EncryptedKey = { encryptedKey: string, }; @@ -38,7 +40,7 @@ export type HttpRequest = { model: "http_request", id: string, createdAt: string export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, }; -export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, }; +export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, headers: Array, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, }; export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, }; @@ -47,7 +49,7 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea * This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support. * The `From` impl is in yaak-http to avoid circular dependencies. */ -export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, }; +export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array, duration: bigint, overridden: boolean, }; export type HttpResponseHeader = { name: string, value: string, }; @@ -91,6 +93,6 @@ export type WebsocketMessageType = "text" | "binary"; export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, description: string, headers: Array, message: string, name: string, sortPriority: number, url: string, urlParameters: Array, }; -export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, }; +export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array, }; export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, }; diff --git a/crates/yaak-models/migrations/20260111000000_dns-timing.sql b/crates/yaak-models/migrations/20260111000000_dns-timing.sql new file mode 100644 index 00000000..c7f2807c --- /dev/null +++ b/crates/yaak-models/migrations/20260111000000_dns-timing.sql @@ -0,0 +1,2 @@ +-- Add DNS resolution timing to http_responses +ALTER TABLE http_responses ADD COLUMN elapsed_dns INTEGER DEFAULT 0 NOT NULL; diff --git a/crates/yaak-models/migrations/20260112000000_dns-overrides.sql b/crates/yaak-models/migrations/20260112000000_dns-overrides.sql new file mode 100644 index 00000000..4f4d6529 --- /dev/null +++ b/crates/yaak-models/migrations/20260112000000_dns-overrides.sql @@ -0,0 +1,2 @@ +-- Add DNS overrides setting to workspaces +ALTER TABLE workspaces ADD COLUMN setting_dns_overrides TEXT DEFAULT '[]' NOT NULL; diff --git a/crates/yaak-models/src/models.rs b/crates/yaak-models/src/models.rs index 5a8eda22..93988fd4 100644 --- a/crates/yaak-models/src/models.rs +++ b/crates/yaak-models/src/models.rs @@ -73,6 +73,20 @@ pub struct ClientCertificate { pub enabled: bool, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_models.ts")] +pub struct DnsOverride { + pub hostname: String, + #[serde(default)] + pub ipv4: Vec, + #[serde(default)] + pub ipv6: Vec, + #[serde(default = "default_true")] + #[ts(optional, as = "Option")] + pub enabled: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(rename_all = "snake_case")] #[ts(export, export_to = "gen_models.ts")] @@ -303,6 +317,8 @@ pub struct Workspace { #[serde(default = "default_true")] pub setting_follow_redirects: bool, pub setting_request_timeout: i32, + #[serde(default)] + pub setting_dns_overrides: Vec, } impl UpsertModelInfo for Workspace { @@ -343,6 +359,7 @@ impl UpsertModelInfo for Workspace { (SettingFollowRedirects, self.setting_follow_redirects.into()), (SettingRequestTimeout, self.setting_request_timeout.into()), (SettingValidateCertificates, self.setting_validate_certificates.into()), + (SettingDnsOverrides, serde_json::to_string(&self.setting_dns_overrides)?.into()), ]) } @@ -359,6 +376,7 @@ impl UpsertModelInfo for Workspace { WorkspaceIden::SettingFollowRedirects, WorkspaceIden::SettingRequestTimeout, WorkspaceIden::SettingValidateCertificates, + WorkspaceIden::SettingDnsOverrides, ] } @@ -368,6 +386,7 @@ impl UpsertModelInfo for Workspace { { let headers: String = row.get("headers")?; let authentication: String = row.get("authentication")?; + let setting_dns_overrides: String = row.get("setting_dns_overrides")?; Ok(Self { id: row.get("id")?, model: row.get("model")?, @@ -382,6 +401,7 @@ impl UpsertModelInfo for Workspace { setting_follow_redirects: row.get("setting_follow_redirects")?, setting_request_timeout: row.get("setting_request_timeout")?, setting_validate_certificates: row.get("setting_validate_certificates")?, + setting_dns_overrides: serde_json::from_str(&setting_dns_overrides).unwrap_or_default(), }) } } @@ -1333,6 +1353,7 @@ pub struct HttpResponse { pub content_length_compressed: Option, pub elapsed: i32, pub elapsed_headers: i32, + pub elapsed_dns: i32, pub error: Option, pub headers: Vec, pub remote_addr: Option, @@ -1381,6 +1402,7 @@ impl UpsertModelInfo for HttpResponse { (ContentLengthCompressed, self.content_length_compressed.into()), (Elapsed, self.elapsed.into()), (ElapsedHeaders, self.elapsed_headers.into()), + (ElapsedDns, self.elapsed_dns.into()), (Error, self.error.into()), (Headers, serde_json::to_string(&self.headers)?.into()), (RemoteAddr, self.remote_addr.into()), @@ -1402,6 +1424,7 @@ impl UpsertModelInfo for HttpResponse { HttpResponseIden::ContentLengthCompressed, HttpResponseIden::Elapsed, HttpResponseIden::ElapsedHeaders, + HttpResponseIden::ElapsedDns, HttpResponseIden::Error, HttpResponseIden::Headers, HttpResponseIden::RemoteAddr, @@ -1435,6 +1458,7 @@ impl UpsertModelInfo for HttpResponse { version: r.get("version")?, elapsed: r.get("elapsed")?, elapsed_headers: r.get("elapsed_headers")?, + elapsed_dns: r.get("elapsed_dns").unwrap_or_default(), remote_addr: r.get("remote_addr")?, status: r.get("status")?, status_reason: r.get("status_reason")?, @@ -1491,6 +1515,12 @@ pub enum HttpResponseEventData { ChunkReceived { bytes: usize, }, + DnsResolved { + hostname: String, + addresses: Vec, + duration: u64, + overridden: bool, + }, } impl Default for HttpResponseEventData { diff --git a/crates/yaak-plugins/bindings/gen_models.ts b/crates/yaak-plugins/bindings/gen_models.ts index 61f6c54d..1963f828 100644 --- a/crates/yaak-plugins/bindings/gen_models.ts +++ b/crates/yaak-plugins/bindings/gen_models.ts @@ -12,6 +12,8 @@ export type CookieExpires = { "AtUtc": string } | "SessionEnd"; export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array, name: string, }; +export type DnsOverride = { hostname: string, ipv4: Array, ipv6: Array, enabled?: boolean, }; + export type EditorKeymap = "default" | "vim" | "vscode" | "emacs"; export type EncryptedKey = { encryptedKey: string, }; @@ -38,7 +40,7 @@ export type HttpRequest = { model: "http_request", id: string, createdAt: string export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, }; -export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, }; +export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, headers: Array, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, }; export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, }; @@ -47,7 +49,7 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea * This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support. * The `From` impl is in yaak-http to avoid circular dependencies. */ -export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, }; +export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array, duration: bigint, overridden: boolean, }; export type HttpResponseHeader = { name: string, value: string, }; @@ -77,6 +79,6 @@ export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping" export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, description: string, headers: Array, message: string, name: string, sortPriority: number, url: string, urlParameters: Array, }; -export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, }; +export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array, }; export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, }; diff --git a/crates/yaak-plugins/src/api.rs b/crates/yaak-plugins/src/api.rs index 2d84e6f6..ad16dad9 100644 --- a/crates/yaak-plugins/src/api.rs +++ b/crates/yaak-plugins/src/api.rs @@ -80,10 +80,7 @@ pub async fn check_plugin_updates( } /// Search for plugins in the registry. -pub async fn search_plugins( - http_client: &Client, - query: &str, -) -> Result { +pub async fn search_plugins(http_client: &Client, query: &str) -> Result { let mut url = build_url("/search"); { let mut query_pairs = url.query_pairs_mut(); diff --git a/crates/yaak-plugins/src/native_template_functions.rs b/crates/yaak-plugins/src/native_template_functions.rs index 56c97e95..f6b9db11 100644 --- a/crates/yaak-plugins/src/native_template_functions.rs +++ b/crates/yaak-plugins/src/native_template_functions.rs @@ -196,7 +196,11 @@ pub fn decrypt_secure_template_function( } } new_tokens.push(Token::Raw { - text: template_function_secure_run(encryption_manager, args_map, plugin_context)?, + text: template_function_secure_run( + encryption_manager, + args_map, + plugin_context, + )?, }); } t => { @@ -216,7 +220,8 @@ pub fn encrypt_secure_template_function( plugin_context: &PluginContext, template: &str, ) -> Result { - let decrypted = decrypt_secure_template_function(&encryption_manager, plugin_context, template)?; + let decrypted = + decrypt_secure_template_function(&encryption_manager, plugin_context, template)?; let tokens = Tokens { tokens: vec![Token::Tag { val: Val::Fn { @@ -231,7 +236,12 @@ pub fn encrypt_secure_template_function( Ok(transform_args( tokens, - &PluginTemplateCallback::new(plugin_manager, encryption_manager, plugin_context, RenderPurpose::Preview), + &PluginTemplateCallback::new( + plugin_manager, + encryption_manager, + plugin_context, + RenderPurpose::Preview, + ), )? .to_string()) } diff --git a/crates/yaak-plugins/src/template_callback.rs b/crates/yaak-plugins/src/template_callback.rs index 1c6ff7d1..3bd60ad3 100644 --- a/crates/yaak-plugins/src/template_callback.rs +++ b/crates/yaak-plugins/src/template_callback.rs @@ -46,7 +46,11 @@ impl TemplateCallback for PluginTemplateCallback { let fn_name = if fn_name == "Response" { "response" } else { fn_name }; if fn_name == "secure" { - return template_function_secure_run(&self.encryption_manager, args, &self.plugin_context); + return template_function_secure_run( + &self.encryption_manager, + args, + &self.plugin_context, + ); } else if fn_name == "keychain" || fn_name == "keyring" { return template_function_keychain_run(args); } @@ -56,7 +60,8 @@ impl TemplateCallback for PluginTemplateCallback { primitive_args.insert(key, JsonPrimitive::from(value)); } - let resp = self.plugin_manager + let resp = self + .plugin_manager .call_template_function( &self.plugin_context, fn_name, diff --git a/crates/yaak-sync/bindings/gen_models.ts b/crates/yaak-sync/bindings/gen_models.ts index 6a397665..da04f231 100644 --- a/crates/yaak-sync/bindings/gen_models.ts +++ b/crates/yaak-sync/bindings/gen_models.ts @@ -1,5 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +export type DnsOverride = { hostname: string, ipv4: Array, ipv6: Array, enabled?: boolean, }; + export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array, color: string | null, sortPriority: number, }; export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, }; @@ -20,4 +22,4 @@ export type SyncState = { model: "sync_state", id: string, workspaceId: string, export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, description: string, headers: Array, message: string, name: string, sortPriority: number, url: string, urlParameters: Array, }; -export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, }; +export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array, }; diff --git a/crates/yaak-sync/src/sync.rs b/crates/yaak-sync/src/sync.rs index 566dad72..2d2287b2 100644 --- a/crates/yaak-sync/src/sync.rs +++ b/crates/yaak-sync/src/sync.rs @@ -296,11 +296,7 @@ pub fn compute_sync_ops( .collect() } -fn workspace_models( - db: &DbContext, - version: &str, - workspace_id: &str, -) -> Result> { +fn workspace_models(db: &DbContext, version: &str, workspace_id: &str) -> Result> { // We want to include private environments here so that we can take them into account during // the sync process. Otherwise, they would be treated as deleted. let include_private_environments = true; diff --git a/crates/yaak-ws/src/manager.rs b/crates/yaak-ws/src/manager.rs index 11c56417..77f4aa75 100644 --- a/crates/yaak-ws/src/manager.rs +++ b/crates/yaak-ws/src/manager.rs @@ -2,6 +2,7 @@ use crate::connect::ws_connect; use crate::error::Result; use futures_util::stream::SplitSink; use futures_util::{SinkExt, StreamExt}; +use http::HeaderMap; use log::{debug, info, warn}; use std::collections::HashMap; use std::sync::Arc; @@ -10,7 +11,6 @@ use tokio::net::TcpStream; use tokio::sync::{Mutex, mpsc}; use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::handshake::client::Response; -use http::HeaderMap; use tokio_tungstenite::tungstenite::http::HeaderValue; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; use yaak_tls::ClientCertificateConfig; diff --git a/packages/plugin-runtime-types/src/bindings/gen_models.ts b/packages/plugin-runtime-types/src/bindings/gen_models.ts index 61f6c54d..87c32f40 100644 --- a/packages/plugin-runtime-types/src/bindings/gen_models.ts +++ b/packages/plugin-runtime-types/src/bindings/gen_models.ts @@ -38,7 +38,7 @@ export type HttpRequest = { model: "http_request", id: string, createdAt: string export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, }; -export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, }; +export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, headers: Array, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, }; export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, }; @@ -47,7 +47,7 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea * This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support. * The `From` impl is in yaak-http to avoid circular dependencies. */ -export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, }; +export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array, duration: bigint, overridden: boolean, }; export type HttpResponseHeader = { name: string, value: string, }; diff --git a/src-web/components/DnsOverridesEditor.tsx b/src-web/components/DnsOverridesEditor.tsx new file mode 100644 index 00000000..119ce8a5 --- /dev/null +++ b/src-web/components/DnsOverridesEditor.tsx @@ -0,0 +1,181 @@ +import type { DnsOverride, Workspace } from '@yaakapp-internal/models'; +import { patchModel } from '@yaakapp-internal/models'; +import { useCallback, useId, useMemo } from 'react'; +import { Button } from './core/Button'; +import { Checkbox } from './core/Checkbox'; +import { IconButton } from './core/IconButton'; +import { PlainInput } from './core/PlainInput'; +import { HStack, VStack } from './core/Stacks'; +import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from './core/Table'; + +interface Props { + workspace: Workspace; +} + +interface DnsOverrideWithId extends DnsOverride { + _id: string; +} + +export function DnsOverridesEditor({ workspace }: Props) { + const reactId = useId(); + + // Ensure each override has an internal ID for React keys + const overridesWithIds = useMemo(() => { + return workspace.settingDnsOverrides.map((override, index) => ({ + ...override, + _id: `${reactId}-${index}`, + })); + }, [workspace.settingDnsOverrides, reactId]); + + const handleChange = useCallback( + (overrides: DnsOverride[]) => { + patchModel(workspace, { settingDnsOverrides: overrides }); + }, + [workspace], + ); + + const handleAdd = useCallback(() => { + const newOverride: DnsOverride = { + hostname: '', + ipv4: [''], + ipv6: [], + enabled: true, + }; + handleChange([...workspace.settingDnsOverrides, newOverride]); + }, [workspace.settingDnsOverrides, handleChange]); + + const handleUpdate = useCallback( + (index: number, update: Partial) => { + const updated = workspace.settingDnsOverrides.map((o, i) => + i === index ? { ...o, ...update } : o, + ); + handleChange(updated); + }, + [workspace.settingDnsOverrides, handleChange], + ); + + const handleDelete = useCallback( + (index: number) => { + const updated = workspace.settingDnsOverrides.filter((_, i) => i !== index); + handleChange(updated); + }, + [workspace.settingDnsOverrides, handleChange], + ); + + return ( + +
+ Override DNS resolution for specific hostnames. This works like{' '} + /etc/hosts{' '} + but only for requests made from this workspace. +
+ + {overridesWithIds.length > 0 && ( + + + + + Hostname + IPv4 Address + IPv6 Address + + + + + {overridesWithIds.map((override, index) => ( + handleUpdate(index, update)} + onDelete={() => handleDelete(index)} + /> + ))} + +
+ )} + + + + +
+ ); +} + +interface DnsOverrideRowProps { + override: DnsOverride; + onUpdate: (update: Partial) => void; + onDelete: () => void; +} + +function DnsOverrideRow({ override, onUpdate, onDelete }: DnsOverrideRowProps) { + const ipv4Value = override.ipv4.join(', '); + const ipv6Value = override.ipv6.join(', '); + + return ( + + + onUpdate({ enabled })} + /> + + + onUpdate({ hostname })} + /> + + + + onUpdate({ + ipv4: value + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + }) + } + /> + + + + onUpdate({ + ipv6: value + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + }) + } + /> + + + + + + ); +} diff --git a/src-web/components/HttpResponseTimeline.tsx b/src-web/components/HttpResponseTimeline.tsx index 556991da..b0122700 100644 --- a/src-web/components/HttpResponseTimeline.tsx +++ b/src-web/components/HttpResponseTimeline.tsx @@ -188,6 +188,35 @@ function EventDetails({ ); } + // DNS Resolution - show hostname, addresses, and timing + if (e.type === 'dns_resolved') { + return ( +
+ + + {e.hostname} + {e.addresses.join(', ')} + + {e.overridden ? ( + -- + ) : ( + `${String(e.duration)}ms` + )} + + + {e.overridden && ( + + Workspace Override + + )} +
+ ); + } + // Default - use summary const { summary } = getEventDisplay(event.event); return ( @@ -219,6 +248,11 @@ function formatEventRaw(event: HttpResponseEventData): string { return `[${formatBytes(event.bytes)} sent]`; case 'chunk_received': return `[${formatBytes(event.bytes)} received]`; + case 'dns_resolved': + if (event.overridden) { + return `DNS override ${event.hostname} → ${event.addresses.join(', ')}`; + } + return `DNS resolved ${event.hostname} → ${event.addresses.join(', ')} (${event.duration}ms)`; default: return '[unknown event]'; } @@ -297,6 +331,15 @@ function getEventDisplay(event: HttpResponseEventData): EventDisplay { label: 'Chunk', summary: `${formatBytes(event.bytes)} chunk received`, }; + case 'dns_resolved': + return { + icon: 'globe', + color: event.overridden ? 'success' : 'secondary', + label: event.overridden ? 'DNS Override' : 'DNS', + summary: event.overridden + ? `${event.hostname} → ${event.addresses.join(', ')} (overridden)` + : `${event.hostname} → ${event.addresses.join(', ')} (${event.duration}ms)`, + }; default: return { icon: 'info', diff --git a/src-web/components/WorkspaceSettingsDialog.tsx b/src-web/components/WorkspaceSettingsDialog.tsx index bb0cc1a3..3c2977c0 100644 --- a/src-web/components/WorkspaceSettingsDialog.tsx +++ b/src-web/components/WorkspaceSettingsDialog.tsx @@ -13,6 +13,7 @@ import { InlineCode } from './core/InlineCode'; import { PlainInput } from './core/PlainInput'; import { HStack, VStack } from './core/Stacks'; import { TabContent, Tabs } from './core/Tabs/Tabs'; +import { DnsOverridesEditor } from './DnsOverridesEditor'; import { HeadersEditor } from './HeadersEditor'; import { HttpAuthenticationEditor } from './HttpAuthenticationEditor'; import { MarkdownEditor } from './MarkdownEditor'; @@ -27,11 +28,13 @@ interface Props { const TAB_AUTH = 'auth'; const TAB_DATA = 'data'; +const TAB_DNS = 'dns'; const TAB_HEADERS = 'headers'; const TAB_GENERAL = 'general'; export type WorkspaceSettingsTab = | typeof TAB_AUTH + | typeof TAB_DNS | typeof TAB_HEADERS | typeof TAB_GENERAL | typeof TAB_DATA; @@ -75,6 +78,7 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) { value: TAB_DATA, label: 'Storage', }, + { value: TAB_DNS, label: 'DNS' }, ...headersTab, ...authTab, ]} @@ -153,6 +157,9 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) { + + + ); } diff --git a/src-web/components/core/HttpResponseDurationTag.tsx b/src-web/components/core/HttpResponseDurationTag.tsx index b143b9e9..21bae26c 100644 --- a/src-web/components/core/HttpResponseDurationTag.tsx +++ b/src-web/components/core/HttpResponseDurationTag.tsx @@ -19,7 +19,8 @@ export function HttpResponseDurationTag({ response }: Props) { return () => clearInterval(timeout.current); }, [response.createdAt, response.state]); - const title = `HEADER: ${formatMillis(response.elapsedHeaders)}\nTOTAL: ${formatMillis(response.elapsed)}`; + const dnsValue = response.elapsedDns > 0 ? formatMillis(response.elapsedDns) : '--'; + const title = `DNS: ${dnsValue}\nHEADER: ${formatMillis(response.elapsedHeaders)}\nTOTAL: ${formatMillis(response.elapsed)}`; const elapsed = response.state === 'closed' ? response.elapsed : fallbackElapsed; diff --git a/src-web/components/core/Icon.tsx b/src-web/components/core/Icon.tsx index 52888d50..b4959171 100644 --- a/src-web/components/core/Icon.tsx +++ b/src-web/components/core/Icon.tsx @@ -78,6 +78,7 @@ import { GitCommitVerticalIcon, GitForkIcon, GitPullRequestIcon, + GlobeIcon, GripVerticalIcon, HandIcon, HardDriveDownloadIcon, @@ -212,6 +213,7 @@ const icons = { git_commit_vertical: GitCommitVerticalIcon, git_fork: GitForkIcon, git_pull_request: GitPullRequestIcon, + globe: GlobeIcon, grip_vertical: GripVerticalIcon, circle_off: CircleOffIcon, hand: HandIcon,