feat: client certificates and ability to skip ssl cert verification in desktop app (#4111)
Co-authored-by: Nivedin <53208152+nivedin@users.noreply.github.com>
This commit is contained in:
146
packages/hoppscotch-common/src/components.d.ts
vendored
146
packages/hoppscotch-common/src/components.d.ts
vendored
@@ -1,12 +1,16 @@
|
|||||||
// generated by unplugin-vue-components
|
// generated by unplugin-vue-components
|
||||||
// We suggest you to commit this file into source control
|
// We suggest you to commit this file into source control
|
||||||
// Read more: https://github.com/vuejs/core/pull/3399
|
// Read more: https://github.com/vuejs/core/pull/3399
|
||||||
import "@vue/runtime-core"
|
import '@vue/runtime-core'
|
||||||
|
|
||||||
export {}
|
export {}
|
||||||
|
|
||||||
declare module "@vue/runtime-core" {
|
declare module '@vue/runtime-core' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
|
AccessTokens: typeof import('./components/accessTokens/index.vue')['default']
|
||||||
|
AccessTokensGenerateModal: typeof import('./components/accessTokens/GenerateModal.vue')['default']
|
||||||
|
AccessTokensList: typeof import('./components/accessTokens/List.vue')['default']
|
||||||
|
AccessTokensOverview: typeof import('./components/accessTokens/Overview.vue')['default']
|
||||||
AppActionHandler: typeof import('./components/app/ActionHandler.vue')['default']
|
AppActionHandler: typeof import('./components/app/ActionHandler.vue')['default']
|
||||||
AppBanner: typeof import('./components/app/Banner.vue')['default']
|
AppBanner: typeof import('./components/app/Banner.vue')['default']
|
||||||
AppContextMenu: typeof import('./components/app/ContextMenu.vue')['default']
|
AppContextMenu: typeof import('./components/app/ContextMenu.vue')['default']
|
||||||
@@ -123,7 +127,6 @@ declare module "@vue/runtime-core" {
|
|||||||
HttpBody: typeof import('./components/http/Body.vue')['default']
|
HttpBody: typeof import('./components/http/Body.vue')['default']
|
||||||
HttpBodyParameters: typeof import('./components/http/BodyParameters.vue')['default']
|
HttpBodyParameters: typeof import('./components/http/BodyParameters.vue')['default']
|
||||||
HttpCodegen: typeof import('./components/http/Codegen.vue')['default']
|
HttpCodegen: typeof import('./components/http/Codegen.vue')['default']
|
||||||
HttpCodegenBody: typeof import('./components/http/CodegenBody.vue')['default']
|
|
||||||
HttpCodegenModal: typeof import('./components/http/CodegenModal.vue')['default']
|
HttpCodegenModal: typeof import('./components/http/CodegenModal.vue')['default']
|
||||||
HttpHeaders: typeof import('./components/http/Headers.vue')['default']
|
HttpHeaders: typeof import('./components/http/Headers.vue')['default']
|
||||||
HttpImportCurl: typeof import('./components/http/ImportCurl.vue')['default']
|
HttpImportCurl: typeof import('./components/http/ImportCurl.vue')['default']
|
||||||
@@ -150,71 +153,76 @@ declare module "@vue/runtime-core" {
|
|||||||
IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default']
|
IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default']
|
||||||
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
|
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
|
||||||
IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default']
|
IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default']
|
||||||
IconLucideBrush: (typeof import("~icons/lucide/brush"))["default"]
|
IconLucideBrush: typeof import('~icons/lucide/brush')['default']
|
||||||
IconLucideCheckCircle: (typeof import("~icons/lucide/check-circle"))["default"]
|
IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default']
|
||||||
IconLucideChevronRight: (typeof import("~icons/lucide/chevron-right"))["default"]
|
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
|
||||||
IconLucideGlobe: (typeof import("~icons/lucide/globe"))["default"]
|
IconLucideGlobe: typeof import('~icons/lucide/globe')['default']
|
||||||
IconLucideHelpCircle: (typeof import("~icons/lucide/help-circle"))["default"]
|
IconLucideHelpCircle: typeof import('~icons/lucide/help-circle')['default']
|
||||||
IconLucideInbox: (typeof import("~icons/lucide/inbox"))["default"]
|
IconLucideInbox: typeof import('~icons/lucide/inbox')['default']
|
||||||
IconLucideInfo: (typeof import("~icons/lucide/info"))["default"]
|
IconLucideInfo: typeof import('~icons/lucide/info')['default']
|
||||||
IconLucideLayers: (typeof import("~icons/lucide/layers"))["default"]
|
IconLucideLayers: typeof import('~icons/lucide/layers')['default']
|
||||||
IconLucideListEnd: (typeof import("~icons/lucide/list-end"))["default"]
|
IconLucideListEnd: typeof import('~icons/lucide/list-end')['default']
|
||||||
IconLucideMinus: (typeof import("~icons/lucide/minus"))["default"]
|
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
|
||||||
IconLucideRss: (typeof import("~icons/lucide/rss"))["default"]
|
IconLucideRss: typeof import('~icons/lucide/rss')['default']
|
||||||
IconLucideSearch: (typeof import("~icons/lucide/search"))["default"]
|
IconLucideSearch: typeof import('~icons/lucide/search')['default']
|
||||||
IconLucideUsers: (typeof import("~icons/lucide/users"))["default"]
|
IconLucideUsers: typeof import('~icons/lucide/users')['default']
|
||||||
IconLucideVerified: (typeof import("~icons/lucide/verified"))["default"]
|
IconLucideVerified: typeof import('~icons/lucide/verified')['default']
|
||||||
IconLucideX: (typeof import("~icons/lucide/x"))["default"]
|
IconLucideX: typeof import('~icons/lucide/x')['default']
|
||||||
ImportExportBase: (typeof import("./components/importExport/Base.vue"))["default"]
|
ImportExportBase: typeof import('./components/importExport/Base.vue')['default']
|
||||||
ImportExportImportExportList: (typeof import("./components/importExport/ImportExportList.vue"))["default"]
|
ImportExportImportExportList: typeof import('./components/importExport/ImportExportList.vue')['default']
|
||||||
ImportExportImportExportSourcesList: (typeof import("./components/importExport/ImportExportSourcesList.vue"))["default"]
|
ImportExportImportExportSourcesList: typeof import('./components/importExport/ImportExportSourcesList.vue')['default']
|
||||||
ImportExportImportExportStepsFileImport: (typeof import("./components/importExport/ImportExportSteps/FileImport.vue"))["default"]
|
ImportExportImportExportStepsFileImport: typeof import('./components/importExport/ImportExportSteps/FileImport.vue')['default']
|
||||||
ImportExportImportExportStepsMyCollectionImport: (typeof import("./components/importExport/ImportExportSteps/MyCollectionImport.vue"))["default"]
|
ImportExportImportExportStepsMyCollectionImport: typeof import('./components/importExport/ImportExportSteps/MyCollectionImport.vue')['default']
|
||||||
ImportExportImportExportStepsUrlImport: (typeof import("./components/importExport/ImportExportSteps/UrlImport.vue"))["default"]
|
ImportExportImportExportStepsUrlImport: typeof import('./components/importExport/ImportExportSteps/UrlImport.vue')['default']
|
||||||
InterceptorsErrorPlaceholder: (typeof import("./components/interceptors/ErrorPlaceholder.vue"))["default"]
|
InterceptorsErrorPlaceholder: typeof import('./components/interceptors/ErrorPlaceholder.vue')['default']
|
||||||
InterceptorsExtensionSubtitle: (typeof import("./components/interceptors/ExtensionSubtitle.vue"))["default"]
|
InterceptorsExtensionSubtitle: typeof import('./components/interceptors/ExtensionSubtitle.vue')['default']
|
||||||
LensesHeadersRenderer: (typeof import("./components/lenses/HeadersRenderer.vue"))["default"]
|
LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default']
|
||||||
LensesHeadersRendererEntry: (typeof import("./components/lenses/HeadersRendererEntry.vue"))["default"]
|
LensesHeadersRendererEntry: typeof import('./components/lenses/HeadersRendererEntry.vue')['default']
|
||||||
LensesRenderersAudioLensRenderer: (typeof import("./components/lenses/renderers/AudioLensRenderer.vue"))["default"]
|
LensesRenderersAudioLensRenderer: typeof import('./components/lenses/renderers/AudioLensRenderer.vue')['default']
|
||||||
LensesRenderersHTMLLensRenderer: (typeof import("./components/lenses/renderers/HTMLLensRenderer.vue"))["default"]
|
LensesRenderersHTMLLensRenderer: typeof import('./components/lenses/renderers/HTMLLensRenderer.vue')['default']
|
||||||
LensesRenderersImageLensRenderer: (typeof import("./components/lenses/renderers/ImageLensRenderer.vue"))["default"]
|
LensesRenderersImageLensRenderer: typeof import('./components/lenses/renderers/ImageLensRenderer.vue')['default']
|
||||||
LensesRenderersJSONLensRenderer: (typeof import("./components/lenses/renderers/JSONLensRenderer.vue"))["default"]
|
LensesRenderersJSONLensRenderer: typeof import('./components/lenses/renderers/JSONLensRenderer.vue')['default']
|
||||||
LensesRenderersPDFLensRenderer: (typeof import("./components/lenses/renderers/PDFLensRenderer.vue"))["default"]
|
LensesRenderersPDFLensRenderer: typeof import('./components/lenses/renderers/PDFLensRenderer.vue')['default']
|
||||||
LensesRenderersRawLensRenderer: (typeof import("./components/lenses/renderers/RawLensRenderer.vue"))["default"]
|
LensesRenderersRawLensRenderer: typeof import('./components/lenses/renderers/RawLensRenderer.vue')['default']
|
||||||
LensesRenderersVideoLensRenderer: (typeof import("./components/lenses/renderers/VideoLensRenderer.vue"))["default"]
|
LensesRenderersVideoLensRenderer: typeof import('./components/lenses/renderers/VideoLensRenderer.vue')['default']
|
||||||
LensesRenderersXMLLensRenderer: (typeof import("./components/lenses/renderers/XMLLensRenderer.vue"))["default"]
|
LensesRenderersXMLLensRenderer: typeof import('./components/lenses/renderers/XMLLensRenderer.vue')['default']
|
||||||
LensesResponseBodyRenderer: (typeof import("./components/lenses/ResponseBodyRenderer.vue"))["default"]
|
LensesResponseBodyRenderer: typeof import('./components/lenses/ResponseBodyRenderer.vue')['default']
|
||||||
ProfileUserDelete: (typeof import("./components/profile/UserDelete.vue"))["default"]
|
ModalsNativeCACertificates: typeof import('./../../hoppscotch-selfhost-desktop/src/components/modals/NativeCACertificates.vue')['default']
|
||||||
RealtimeCommunication: (typeof import("./components/realtime/Communication.vue"))["default"]
|
ModalsNativeClientCertificates: typeof import('./../../hoppscotch-selfhost-desktop/src/components/modals/NativeClientCertificates.vue')['default']
|
||||||
RealtimeConnectionConfig: (typeof import("./components/realtime/ConnectionConfig.vue"))["default"]
|
ModalsNativeClientCertsAdd: typeof import('./../../hoppscotch-selfhost-desktop/src/components/modals/NativeClientCertsAdd.vue')['default']
|
||||||
RealtimeLog: (typeof import("./components/realtime/Log.vue"))["default"]
|
ProfileUserDelete: typeof import('./components/profile/UserDelete.vue')['default']
|
||||||
RealtimeLogEntry: (typeof import("./components/realtime/LogEntry.vue"))["default"]
|
RealtimeCommunication: typeof import('./components/realtime/Communication.vue')['default']
|
||||||
RealtimeSubscription: (typeof import("./components/realtime/Subscription.vue"))["default"]
|
RealtimeConnectionConfig: typeof import('./components/realtime/ConnectionConfig.vue')['default']
|
||||||
SettingsExtension: (typeof import("./components/settings/Extension.vue"))["default"]
|
RealtimeLog: typeof import('./components/realtime/Log.vue')['default']
|
||||||
SettingsProxy: (typeof import("./components/settings/Proxy.vue"))["default"]
|
RealtimeLogEntry: typeof import('./components/realtime/LogEntry.vue')['default']
|
||||||
Share: (typeof import("./components/share/index.vue"))["default"]
|
RealtimeSubscription: typeof import('./components/realtime/Subscription.vue')['default']
|
||||||
ShareCreateModal: (typeof import("./components/share/CreateModal.vue"))["default"]
|
SettingsExtension: typeof import('./components/settings/Extension.vue')['default']
|
||||||
ShareCustomizeModal: (typeof import("./components/share/CustomizeModal.vue"))["default"]
|
SettingsNativeInterceptor: typeof import('./../../hoppscotch-selfhost-desktop/src/components/settings/NativeInterceptor.vue')['default']
|
||||||
ShareModal: (typeof import("./components/share/Modal.vue"))["default"]
|
SettingsProxy: typeof import('./components/settings/Proxy.vue')['default']
|
||||||
ShareRequest: (typeof import("./components/share/Request.vue"))["default"]
|
Share: typeof import('./components/share/index.vue')['default']
|
||||||
ShareTemplatesButton: (typeof import("./components/share/templates/Button.vue"))["default"]
|
ShareCreateModal: typeof import('./components/share/CreateModal.vue')['default']
|
||||||
ShareTemplatesEmbeds: (typeof import("./components/share/templates/Embeds.vue"))["default"]
|
ShareCustomizeModal: typeof import('./components/share/CustomizeModal.vue')['default']
|
||||||
ShareTemplatesLink: (typeof import("./components/share/templates/Link.vue"))["default"]
|
ShareModal: typeof import('./components/share/Modal.vue')['default']
|
||||||
SmartAccentModePicker: (typeof import("./components/smart/AccentModePicker.vue"))["default"]
|
ShareRequest: typeof import('./components/share/Request.vue')['default']
|
||||||
SmartChangeLanguage: (typeof import("./components/smart/ChangeLanguage.vue"))["default"]
|
ShareTemplatesButton: typeof import('./components/share/templates/Button.vue')['default']
|
||||||
SmartColorModePicker: (typeof import("./components/smart/ColorModePicker.vue"))["default"]
|
ShareTemplatesEmbeds: typeof import('./components/share/templates/Embeds.vue')['default']
|
||||||
SmartEnvInput: (typeof import("./components/smart/EnvInput.vue"))["default"]
|
ShareTemplatesLink: typeof import('./components/share/templates/Link.vue')['default']
|
||||||
TabPrimary: (typeof import("./components/tab/Primary.vue"))["default"]
|
SmartAccentModePicker: typeof import('./components/smart/AccentModePicker.vue')['default']
|
||||||
TabSecondary: (typeof import("./components/tab/Secondary.vue"))["default"]
|
SmartChangeLanguage: typeof import('./components/smart/ChangeLanguage.vue')['default']
|
||||||
Teams: (typeof import("./components/teams/index.vue"))["default"]
|
SmartColorModePicker: typeof import('./components/smart/ColorModePicker.vue')['default']
|
||||||
TeamsAdd: (typeof import("./components/teams/Add.vue"))["default"]
|
SmartEnvInput: typeof import('./components/smart/EnvInput.vue')['default']
|
||||||
TeamsEdit: (typeof import("./components/teams/Edit.vue"))["default"]
|
TabPrimary: typeof import('./components/tab/Primary.vue')['default']
|
||||||
TeamsInvite: (typeof import("./components/teams/Invite.vue"))["default"]
|
TabSecondary: typeof import('./components/tab/Secondary.vue')['default']
|
||||||
TeamsMemberStack: (typeof import("./components/teams/MemberStack.vue"))["default"]
|
Teams: typeof import('./components/teams/index.vue')['default']
|
||||||
TeamsModal: (typeof import("./components/teams/Modal.vue"))["default"]
|
TeamsAdd: typeof import('./components/teams/Add.vue')['default']
|
||||||
TeamsTeam: (typeof import("./components/teams/Team.vue"))["default"]
|
TeamsEdit: typeof import('./components/teams/Edit.vue')['default']
|
||||||
Tippy: (typeof import("vue-tippy"))["Tippy"]
|
TeamsInvite: typeof import('./components/teams/Invite.vue')['default']
|
||||||
WorkspaceCurrent: (typeof import("./components/workspace/Current.vue"))["default"]
|
TeamsMemberStack: typeof import('./components/teams/MemberStack.vue')['default']
|
||||||
WorkspaceSelector: (typeof import("./components/workspace/Selector.vue"))["default"]
|
TeamsModal: typeof import('./components/teams/Modal.vue')['default']
|
||||||
|
TeamsTeam: typeof import('./components/teams/Team.vue')['default']
|
||||||
|
Tippy: typeof import('vue-tippy')['Tippy']
|
||||||
|
WorkspaceCurrent: typeof import('./components/workspace/Current.vue')['default']
|
||||||
|
WorkspaceSelector: typeof import('./components/workspace/Selector.vue')['default']
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,8 +36,10 @@
|
|||||||
"tauri": "link:@tauri-apps/api/tauri",
|
"tauri": "link:@tauri-apps/api/tauri",
|
||||||
"tauri-plugin-store-api": "0.0.0",
|
"tauri-plugin-store-api": "0.0.0",
|
||||||
"util": "0.12.5",
|
"util": "0.12.5",
|
||||||
|
"verzod": "0.2.2",
|
||||||
"vue": "3.3.9",
|
"vue": "3.3.9",
|
||||||
"workbox-window": "6.6.0"
|
"workbox-window": "6.6.0",
|
||||||
|
"zod": "3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@graphql-codegen/add": "5.0.0",
|
"@graphql-codegen/add": "5.0.0",
|
||||||
|
|||||||
1241
packages/hoppscotch-selfhost-desktop/src-tauri/Cargo.lock
generated
1241
packages/hoppscotch-selfhost-desktop/src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -26,10 +26,14 @@ tauri = { version = "1.5.3", features = [
|
|||||||
tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
|
tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
|
||||||
tauri-plugin-deep-link = { git = "https://github.com/FabianLars/tauri-plugin-deep-link", branch = "main" }
|
tauri-plugin-deep-link = { git = "https://github.com/FabianLars/tauri-plugin-deep-link", branch = "main" }
|
||||||
tauri-plugin-window-state = "0.1.0"
|
tauri-plugin-window-state = "0.1.0"
|
||||||
reqwest = "0.11.22"
|
reqwest = { version = "0.11.22", features = ["native-tls"] }
|
||||||
serde_json = "1.0.108"
|
serde_json = "1.0.108"
|
||||||
url = "2.5.0"
|
url = "2.5.0"
|
||||||
hex_color = "3.0.0"
|
hex_color = "3.0.0"
|
||||||
|
serde = { version = "1.0.203", features = ["derive"] }
|
||||||
|
dashmap = "5.5.3"
|
||||||
|
tokio = { version = "1.38.0", features = ["macros"] }
|
||||||
|
tokio-util = "0.7.11"
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
cocoa = "0.25.0"
|
cocoa = "0.25.0"
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
use dashmap::DashMap;
|
||||||
|
use reqwest::{header::{HeaderMap, HeaderName, HeaderValue}, Certificate, ClientBuilder, Identity};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::{plugin::{Builder, TauriPlugin}, Manager, Runtime, State};
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct InterceptorState {
|
||||||
|
cancellation_tokens: DashMap<usize, CancellationToken>
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct KeyValuePair {
|
||||||
|
key: String,
|
||||||
|
value: String
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
enum FormDataValue {
|
||||||
|
Text(String),
|
||||||
|
File {
|
||||||
|
filename: String,
|
||||||
|
data: Vec<u8>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct FormDataEntry {
|
||||||
|
key: String,
|
||||||
|
value: FormDataValue
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
enum BodyDef {
|
||||||
|
Text(String),
|
||||||
|
URLEncoded(Vec<KeyValuePair>),
|
||||||
|
FormData(Vec<FormDataEntry>)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
enum ClientCertDef {
|
||||||
|
PEMCert {
|
||||||
|
certificate_pem: Vec<u8>,
|
||||||
|
key_pem: Vec<u8>
|
||||||
|
},
|
||||||
|
|
||||||
|
PFXCert {
|
||||||
|
certificate_pfx: Vec<u8>,
|
||||||
|
password: String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct RequestDef {
|
||||||
|
req_id: usize,
|
||||||
|
|
||||||
|
method: String,
|
||||||
|
endpoint: String,
|
||||||
|
|
||||||
|
parameters: Vec<KeyValuePair>,
|
||||||
|
headers: Vec<KeyValuePair>,
|
||||||
|
|
||||||
|
body: Option<BodyDef>,
|
||||||
|
|
||||||
|
validate_certs: bool,
|
||||||
|
root_cert_bundle_files: Vec<Vec<u8>>,
|
||||||
|
client_cert: Option<ClientCertDef>
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_identity_from_req(req: &RequestDef) -> Result<Option<Identity>, reqwest::Error> {
|
||||||
|
let result = match &req.client_cert {
|
||||||
|
None => return Ok(None),
|
||||||
|
Some(ClientCertDef::PEMCert { certificate_pem, key_pem }) => Identity::from_pkcs8_pem(&certificate_pem, &key_pem),
|
||||||
|
Some(ClientCertDef::PFXCert { certificate_pfx, password }) => Identity::from_pkcs12_der(&certificate_pfx, &password)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(result?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_root_certs(req: &RequestDef) -> Result<Vec<Certificate>, reqwest::Error> {
|
||||||
|
let mut result = vec![];
|
||||||
|
|
||||||
|
for cert_bundle_file in &req.root_cert_bundle_files {
|
||||||
|
let mut certs = Certificate::from_pem_bundle(&cert_bundle_file)?;
|
||||||
|
result.append(&mut certs);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ReqBodyAction {
|
||||||
|
Body(reqwest::Body),
|
||||||
|
UrlEncodedForm(Vec<(String, String)>),
|
||||||
|
MultipartForm(reqwest::multipart::Form)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn convert_bodydef_to_req_action(req: &RequestDef) -> Option<ReqBodyAction> {
|
||||||
|
match &req.body {
|
||||||
|
None => None,
|
||||||
|
Some(BodyDef::Text(text)) => Some(ReqBodyAction::Body(text.clone().into())),
|
||||||
|
Some(BodyDef::URLEncoded(entries)) =>
|
||||||
|
Some(
|
||||||
|
ReqBodyAction::UrlEncodedForm(
|
||||||
|
entries.iter()
|
||||||
|
.map(|KeyValuePair { key, value }| (key.clone(), value.clone()))
|
||||||
|
.collect()
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Some(BodyDef::FormData(entries)) => {
|
||||||
|
let mut form = reqwest::multipart::Form::new();
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
form = match &entry.value {
|
||||||
|
FormDataValue::Text(value) => form.text(entry.key.clone(), value.clone()),
|
||||||
|
FormDataValue::File { filename, data } =>
|
||||||
|
form.part(
|
||||||
|
entry.key.clone(),
|
||||||
|
reqwest::multipart::Part::bytes(data.clone())
|
||||||
|
.file_name(filename.clone())
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(ReqBodyAction::MultipartForm(form))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct RunRequestResponse {
|
||||||
|
status: u16,
|
||||||
|
status_text: String,
|
||||||
|
headers: Vec<KeyValuePair>,
|
||||||
|
data: Vec<u8>,
|
||||||
|
|
||||||
|
time_start_ms: u128,
|
||||||
|
time_end_ms: u128
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
enum RunRequestError {
|
||||||
|
RequestCancelled,
|
||||||
|
ClientCertError,
|
||||||
|
RootCertError,
|
||||||
|
InvalidMethod,
|
||||||
|
InvalidUrl,
|
||||||
|
InvalidHeaders,
|
||||||
|
RequestRunError(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute_request(req_builder: reqwest::RequestBuilder) -> Result<RunRequestResponse, RunRequestError> {
|
||||||
|
let start_time_ms = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_millis();
|
||||||
|
|
||||||
|
let response = req_builder.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| RunRequestError::RequestRunError(err.to_string()))?;
|
||||||
|
|
||||||
|
// We hold on to these values becase we lose ownership of response
|
||||||
|
// when we read the body
|
||||||
|
let res_status = response.status();
|
||||||
|
let res_headers = response.headers().clone();
|
||||||
|
|
||||||
|
|
||||||
|
let res_body_bytes = response.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|err| RunRequestError::RequestRunError(err.to_string()))?;
|
||||||
|
|
||||||
|
// Reqwest resolves the send before all the response is loaded, to keep the timing
|
||||||
|
// correctly, we load the response as well.
|
||||||
|
let end_time_ms = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_millis();
|
||||||
|
|
||||||
|
let response_status = res_status.as_u16();
|
||||||
|
let response_status_text = res_status
|
||||||
|
.canonical_reason()
|
||||||
|
.unwrap_or("Unknown Status")
|
||||||
|
.to_owned();
|
||||||
|
|
||||||
|
let response_headers = res_headers
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)|
|
||||||
|
KeyValuePair {
|
||||||
|
key: key.as_str().to_owned(),
|
||||||
|
value: value.to_str().unwrap_or("").to_owned()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(
|
||||||
|
RunRequestResponse {
|
||||||
|
status: response_status,
|
||||||
|
status_text: response_status_text,
|
||||||
|
headers: response_headers,
|
||||||
|
data: res_body_bytes.into(),
|
||||||
|
time_start_ms: start_time_ms,
|
||||||
|
time_end_ms: end_time_ms
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn run_request(req: RequestDef, state: State<'_, InterceptorState>) -> Result<RunRequestResponse, RunRequestError> {
|
||||||
|
let method = reqwest::Method::from_bytes(req.method.as_bytes())
|
||||||
|
.map_err(|_| RunRequestError::InvalidMethod)?;
|
||||||
|
|
||||||
|
let endpoint_url = reqwest::Url::parse(&req.endpoint)
|
||||||
|
.map_err(|_| RunRequestError::InvalidUrl)?;
|
||||||
|
|
||||||
|
let headers = req.headers
|
||||||
|
.iter()
|
||||||
|
.map(|KeyValuePair { key, value }|
|
||||||
|
Ok(
|
||||||
|
(
|
||||||
|
key.parse::<HeaderName>().map_err(|_| ())?,
|
||||||
|
value.parse::<HeaderValue>().map_err(|_| ())?
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.collect::<Result<HeaderMap, ()>>()
|
||||||
|
.map_err(|_| RunRequestError::InvalidHeaders)?;
|
||||||
|
|
||||||
|
let body_action = convert_bodydef_to_req_action(&req);
|
||||||
|
|
||||||
|
let client_identity = get_identity_from_req(&req)
|
||||||
|
.map_err(|_| RunRequestError::ClientCertError)?;
|
||||||
|
|
||||||
|
let root_certs = parse_root_certs(&req)
|
||||||
|
.map_err(|_| RunRequestError::RootCertError)?;
|
||||||
|
|
||||||
|
let mut client_builder = ClientBuilder::new()
|
||||||
|
.danger_accept_invalid_certs(!req.validate_certs);
|
||||||
|
|
||||||
|
// NOTE: Root Certificates are not currently implemented into the Hoppscotch UI
|
||||||
|
// This is done so as the current mechanism doesn't allow for v1 X.509 certificates
|
||||||
|
// to be accepted. Reqwest supports `native-tls` and `rustls`.
|
||||||
|
// `native-tls` should support v1 X.509 in Linux [OpenSSL] (and hopefully on Win [SChannel]), but on
|
||||||
|
// macOS the Security Framework system in it blocks certiticates pretty harshly and blocks v1.
|
||||||
|
// `rustls` doesn't allow v1 x.509 as well as documented here: https://github.com/rustls/webpki/issues/29
|
||||||
|
// We will fully introduce the feature when the dilemma is solved (or demand is voiced), until
|
||||||
|
// then, disabling SSL verification should yield same results
|
||||||
|
for root_cert in root_certs {
|
||||||
|
client_builder = client_builder.add_root_certificate(root_cert);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(identity) = client_identity {
|
||||||
|
client_builder = client_builder.identity(identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = client_builder.build()
|
||||||
|
.expect("TLS Backend couldn't be initialized");
|
||||||
|
|
||||||
|
let mut req_builder = client.request(method, endpoint_url)
|
||||||
|
.query(
|
||||||
|
&req.parameters
|
||||||
|
.iter()
|
||||||
|
.map(|KeyValuePair { key, value }| (key, value))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
)
|
||||||
|
.headers(headers);
|
||||||
|
|
||||||
|
req_builder = match body_action {
|
||||||
|
None => req_builder,
|
||||||
|
Some(ReqBodyAction::Body(body)) => req_builder.body(body),
|
||||||
|
Some(ReqBodyAction::UrlEncodedForm(entries)) => req_builder.form(&entries),
|
||||||
|
Some(ReqBodyAction::MultipartForm(form)) => req_builder.multipart(form)
|
||||||
|
};
|
||||||
|
|
||||||
|
let cancel_token = CancellationToken::new();
|
||||||
|
|
||||||
|
// NOTE: This will drop reference to an existing cancellation token
|
||||||
|
// if you send a request with the same request id as an existing one,
|
||||||
|
// thereby, dropping any means to cancel a running operation with the old token.
|
||||||
|
// This is done so because, on FE side, we may lose cancel token info upon reloads
|
||||||
|
// and this allows us to work around that.
|
||||||
|
state.cancellation_tokens.insert(req.req_id, cancel_token.clone());
|
||||||
|
|
||||||
|
// Races between whether cancellation happened or requext execution happened
|
||||||
|
let result = tokio::select! {
|
||||||
|
_ = cancel_token.cancelled() => { None },
|
||||||
|
result = execute_request(req_builder) => {
|
||||||
|
// Remove cancellation token since the request has now completed
|
||||||
|
state.cancellation_tokens.remove(&req.req_id);
|
||||||
|
|
||||||
|
Some(result)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
result
|
||||||
|
.unwrap_or(Err(RunRequestError::RequestCancelled))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn cancel_request(req_id: usize, state: State<'_, InterceptorState>) {
|
||||||
|
if let Some((_, cancel_token)) = state.cancellation_tokens.remove(&req_id) {
|
||||||
|
cancel_token.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||||
|
Builder::new("hopp_native_interceptor")
|
||||||
|
.invoke_handler(
|
||||||
|
tauri::generate_handler![
|
||||||
|
run_request,
|
||||||
|
cancel_request
|
||||||
|
]
|
||||||
|
)
|
||||||
|
.setup(|app_handle| {
|
||||||
|
app_handle.manage(InterceptorState::default());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ mod mac;
|
|||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
mod win;
|
mod win;
|
||||||
|
|
||||||
|
mod interceptor;
|
||||||
|
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -23,6 +25,7 @@ fn main() {
|
|||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||||
.plugin(tauri_plugin_store::Builder::default().build())
|
.plugin(tauri_plugin_store::Builder::default().build())
|
||||||
|
.plugin(interceptor::init())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
if cfg!(target_os = "macos") {
|
if cfg!(target_os = "macos") {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<template>
|
||||||
|
<HoppSmartModal
|
||||||
|
v-if="show"
|
||||||
|
dialog
|
||||||
|
:title="'CA Certificates'"
|
||||||
|
@close="emit('hide-modal')"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<div class="flex flex-col space-y-4">
|
||||||
|
<ul
|
||||||
|
v-if="certificates.length > 0"
|
||||||
|
class="mx-4 border border-dividerDark rounded"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="(certificate, index) in certificates"
|
||||||
|
:key="index"
|
||||||
|
class="flex border-dividerDark px-2 items-center justify-between"
|
||||||
|
:class="{ 'border-t border-dividerDark': index !== 0 }"
|
||||||
|
>
|
||||||
|
<div class="truncate">
|
||||||
|
{{ certificate.filename }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<HoppButtonSecondary
|
||||||
|
:icon="certificate.enabled ? IconCheckCircle : IconCircle"
|
||||||
|
v-tippy="{ theme: 'tooltip' }"
|
||||||
|
:title="
|
||||||
|
certificate.enabled
|
||||||
|
? t('action.turn_off')
|
||||||
|
: t('action.turn_on')
|
||||||
|
"
|
||||||
|
color="green"
|
||||||
|
@click="toggleEntryEnabled(index)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HoppButtonSecondary
|
||||||
|
:icon="IconTrash"
|
||||||
|
v-tippy="{ theme: 'tooltip' }"
|
||||||
|
:title="t('action.remove')"
|
||||||
|
@click="deleteEntry(index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<HoppButtonSecondary
|
||||||
|
class="mx-4"
|
||||||
|
:icon="IconPlus"
|
||||||
|
:label="'Add Certifcate File'"
|
||||||
|
:loading="selectedFiles && selectedFiles!.length > 0"
|
||||||
|
filled
|
||||||
|
outline
|
||||||
|
@click="openFilePicker"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p class="text-center text-secondaryLight">
|
||||||
|
Hoppscotch supports .crt, .cer or .pem files containing one or more
|
||||||
|
certificates.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<HoppButtonPrimary :label="'Save'" @click="save" />
|
||||||
|
<HoppButtonSecondary
|
||||||
|
:label="'Cancel'"
|
||||||
|
filled
|
||||||
|
outline
|
||||||
|
@click="emit('hide-modal')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</HoppSmartModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- TODO: i18n -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import IconPlus from "~icons/lucide/plus"
|
||||||
|
import IconCheckCircle from "~icons/lucide/check-circle"
|
||||||
|
import IconCircle from "~icons/lucide/circle"
|
||||||
|
import IconTrash from "~icons/lucide/trash"
|
||||||
|
import { useService } from "dioc/vue"
|
||||||
|
import { ref, watch } from "vue"
|
||||||
|
import { useFileDialog } from "@vueuse/core"
|
||||||
|
import { cloneDeep } from "lodash-es"
|
||||||
|
import {
|
||||||
|
NativeInterceptorService,
|
||||||
|
CACertificateEntry,
|
||||||
|
} from "@platform/interceptors/native"
|
||||||
|
import { useI18n } from "@composables/i18n"
|
||||||
|
|
||||||
|
const t = useI18n()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
show: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "hide-modal"): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const nativeInterceptorService = useService(NativeInterceptorService)
|
||||||
|
|
||||||
|
const certificates = ref<CACertificateEntry[]>([])
|
||||||
|
|
||||||
|
const {
|
||||||
|
files: selectedFiles,
|
||||||
|
open: openFilePicker,
|
||||||
|
reset: resetFilePicker,
|
||||||
|
onChange: onSelectedFilesChange,
|
||||||
|
} = useFileDialog({
|
||||||
|
multiple: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// When files are selected, add them to the list of certificates and reset the file list
|
||||||
|
onSelectedFilesChange(async (files) => {
|
||||||
|
if (files) {
|
||||||
|
const addedCertificates: CACertificateEntry[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i]
|
||||||
|
|
||||||
|
const data = new Uint8Array(await file.arrayBuffer())
|
||||||
|
|
||||||
|
addedCertificates.push({
|
||||||
|
filename: file.name,
|
||||||
|
enabled: true,
|
||||||
|
certificate: data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
certificates.value.push(...addedCertificates)
|
||||||
|
|
||||||
|
resetFilePicker()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// When the modal is shown, clone the certificates from the service,
|
||||||
|
// We only write to the service when the user clicks on save
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(show) => {
|
||||||
|
if (show) {
|
||||||
|
certificates.value = cloneDeep(
|
||||||
|
nativeInterceptorService.caCertificates.value
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
resetFilePicker()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
nativeInterceptorService.caCertificates.value = certificates.value
|
||||||
|
emit("hide-modal")
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteEntry(index: number) {
|
||||||
|
certificates.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleEntryEnabled(index: number) {
|
||||||
|
certificates.value[index].enabled = !certificates.value[index].enabled
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<template>
|
||||||
|
<HoppSmartModal
|
||||||
|
v-if="show"
|
||||||
|
dialog
|
||||||
|
:title="'Client Certificates'"
|
||||||
|
@close="emit('hide-modal')"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<div class="flex flex-col space-y-4">
|
||||||
|
<ul
|
||||||
|
v-if="certificateMap.size > 0"
|
||||||
|
class="mx-4 border border-dividerDark rounded"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="([domain, certificate], index) in certificateMap"
|
||||||
|
:key="domain"
|
||||||
|
class="flex border-dividerDark px-2 items-center justify-between"
|
||||||
|
:class="{ 'border-t border-dividerDark': index !== 0 }"
|
||||||
|
>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<div class="truncate">
|
||||||
|
{{ domain }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-1">
|
||||||
|
<div class="text-secondaryLight mr-2">
|
||||||
|
{{ "PEMCert" in certificate.cert ? "PEM" : "PFX/PKCS12" }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HoppButtonSecondary
|
||||||
|
:icon="certificate.enabled ? IconCheckCircle : IconCircle"
|
||||||
|
v-tippy="{ theme: 'tooltip' }"
|
||||||
|
:title="
|
||||||
|
certificate.enabled
|
||||||
|
? t('action.turn_off')
|
||||||
|
: t('action.turn_on')
|
||||||
|
"
|
||||||
|
color="green"
|
||||||
|
@click="toggleEntryEnabled(domain)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HoppButtonSecondary
|
||||||
|
:icon="IconTrash"
|
||||||
|
v-tippy="{ theme: 'tooltip' }"
|
||||||
|
:title="t('action.remove')"
|
||||||
|
color="red"
|
||||||
|
@click="deleteEntry(domain)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<HoppButtonSecondary
|
||||||
|
class="mx-4"
|
||||||
|
:icon="IconPlus"
|
||||||
|
:label="'Add Certificate File'"
|
||||||
|
filled
|
||||||
|
outline
|
||||||
|
@click="showAddModal = true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<HoppButtonPrimary :label="'Save'" @click="save" />
|
||||||
|
<HoppButtonSecondary
|
||||||
|
:label="'Cancel'"
|
||||||
|
filled
|
||||||
|
outline
|
||||||
|
@click="emit('hide-modal')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</HoppSmartModal>
|
||||||
|
|
||||||
|
<ModalsNativeClientCertsAdd
|
||||||
|
:show="showAddModal"
|
||||||
|
:existing-domains="Array.from(certificateMap.keys())"
|
||||||
|
@hide-modal="showAddModal = false"
|
||||||
|
@save="saveCertificate"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- TODO: i18n -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import IconPlus from "~icons/lucide/plus"
|
||||||
|
import IconCheckCircle from "~icons/lucide/check-circle"
|
||||||
|
import IconCircle from "~icons/lucide/circle"
|
||||||
|
import IconTrash from "~icons/lucide/trash"
|
||||||
|
import { ref, watch } from "vue"
|
||||||
|
import { useI18n } from "@composables/i18n"
|
||||||
|
import { useService } from "dioc/vue"
|
||||||
|
import {
|
||||||
|
ClientCertificateEntry,
|
||||||
|
NativeInterceptorService,
|
||||||
|
} from "@platform/interceptors/native"
|
||||||
|
import { cloneDeep } from "lodash-es"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
show: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "hide-modal"): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const t = useI18n()
|
||||||
|
|
||||||
|
const nativeInterceptorService = useService(NativeInterceptorService)
|
||||||
|
|
||||||
|
const certificateMap = ref(new Map<string, ClientCertificateEntry>())
|
||||||
|
|
||||||
|
const showAddModal = ref(false)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(show) => {
|
||||||
|
if (show) {
|
||||||
|
certificateMap.value = cloneDeep(
|
||||||
|
nativeInterceptorService.clientCertificates.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
nativeInterceptorService.clientCertificates.value = cloneDeep(
|
||||||
|
certificateMap.value
|
||||||
|
)
|
||||||
|
emit("hide-modal")
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCertificate(cert: ClientCertificateEntry) {
|
||||||
|
certificateMap.value.set(cert.domain, cert)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleEntryEnabled(domain: string) {
|
||||||
|
const certificate = certificateMap.value.get(domain)
|
||||||
|
|
||||||
|
if (certificate) {
|
||||||
|
certificateMap.value.set(domain, {
|
||||||
|
...certificate,
|
||||||
|
enabled: !certificate.enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteEntry(domain: string) {
|
||||||
|
certificateMap.value.delete(domain)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
<template>
|
||||||
|
<HoppSmartModal
|
||||||
|
v-if="show"
|
||||||
|
dialog
|
||||||
|
:title="'Add Client Certificate'"
|
||||||
|
@close="emit('hide-modal')"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<HoppSmartInput
|
||||||
|
v-model="domain"
|
||||||
|
:autofocus="false"
|
||||||
|
styles="flex-1"
|
||||||
|
placeholder=" "
|
||||||
|
:label="'Domain'"
|
||||||
|
input-styles="input floating-input"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HoppSmartTabs v-model="selectedTab">
|
||||||
|
<HoppSmartTab :id="'pem'" :label="'PEM'">
|
||||||
|
<div class="p-4 space-y-4">
|
||||||
|
<div class="flex flex-col space-y-2">
|
||||||
|
<label> Certificate </label>
|
||||||
|
<HoppButtonSecondary
|
||||||
|
:icon="pemCert?.type === 'loaded' ? IconFile : IconPlus"
|
||||||
|
:loading="pemCert?.type === 'loading'"
|
||||||
|
:label="
|
||||||
|
pemCert?.type === 'loaded'
|
||||||
|
? pemCert.filename
|
||||||
|
: 'Add Certifcate File'
|
||||||
|
"
|
||||||
|
filled
|
||||||
|
outline
|
||||||
|
@click="openFilePicker('pem_cert')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col space-y-2">
|
||||||
|
<label> Key </label>
|
||||||
|
<HoppButtonSecondary
|
||||||
|
:icon="pemKey?.type === 'loaded' ? IconFile : IconPlus"
|
||||||
|
:loading="pemKey?.type === 'loading'"
|
||||||
|
:label="
|
||||||
|
pemKey?.type === 'loaded' ? pemKey.filename : 'Add Key File'
|
||||||
|
"
|
||||||
|
@click="openFilePicker('pem_key')"
|
||||||
|
filled
|
||||||
|
outline
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</HoppSmartTab>
|
||||||
|
|
||||||
|
<HoppSmartTab :id="'pfx'" :label="'PFX/PKCS12'">
|
||||||
|
<div class="p-4 space-y-6">
|
||||||
|
<div class="flex flex-col space-y-2">
|
||||||
|
<label> PFX/PKCS12 File </label>
|
||||||
|
<HoppButtonSecondary
|
||||||
|
:icon="pfxCert?.type === 'loaded' ? IconFile : IconPlus"
|
||||||
|
:loading="pfxCert?.type === 'loading'"
|
||||||
|
:label="
|
||||||
|
pfxCert?.type === 'loaded'
|
||||||
|
? pfxCert.filename
|
||||||
|
: 'Add PFX/PKCS12 File'
|
||||||
|
"
|
||||||
|
@click="openFilePicker('pfx_cert')"
|
||||||
|
filled
|
||||||
|
outline
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border border-divider rounded">
|
||||||
|
<HoppSmartInput
|
||||||
|
v-model="pfxPassword"
|
||||||
|
:type="showPfxPassword ? 'text' : 'password'"
|
||||||
|
:label="'Password'"
|
||||||
|
input-styles="floating-input !border-0 "
|
||||||
|
:placeholder="' '"
|
||||||
|
>
|
||||||
|
<template #button>
|
||||||
|
<HoppButtonSecondary
|
||||||
|
v-tippy="{ theme: 'tooltip' }"
|
||||||
|
:title="
|
||||||
|
showPfxPassword ? 'Hide Password' : 'Show Password'
|
||||||
|
"
|
||||||
|
:icon="showPfxPassword ? IconEye : IconEyeOff"
|
||||||
|
@click="showPfxPassword = !showPfxPassword"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</HoppSmartInput>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</HoppSmartTab>
|
||||||
|
</HoppSmartTabs>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<HoppButtonPrimary
|
||||||
|
:label="'Save'"
|
||||||
|
:disabled="!isValidCertificate || anyFileSelectorIsLoading"
|
||||||
|
@click="save"
|
||||||
|
/>
|
||||||
|
<HoppButtonSecondary
|
||||||
|
:label="'Cancel'"
|
||||||
|
filled
|
||||||
|
outline
|
||||||
|
@click="emit('hide-modal')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</HoppSmartModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- TODO: i18n -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import IconPlus from "~icons/lucide/plus"
|
||||||
|
import IconEyeOff from "~icons/lucide/eye-off"
|
||||||
|
import IconEye from "~icons/lucide/eye"
|
||||||
|
import IconFile from "~icons/lucide/file"
|
||||||
|
import { ref, watch, computed } from "vue"
|
||||||
|
import { useFileDialog } from "@vueuse/core"
|
||||||
|
import { ClientCertificateEntry } from "../../platform/interceptors/native"
|
||||||
|
import { useToast } from "@composables/toast"
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
show: boolean
|
||||||
|
existingDomains: string[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "hide-modal"): void
|
||||||
|
(e: "save", certificate: ClientCertificateEntry): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
type FileSelectorState =
|
||||||
|
| null
|
||||||
|
| { type: "loading" }
|
||||||
|
| { type: "loaded"; filename: string; data: Uint8Array }
|
||||||
|
|
||||||
|
const domain = ref("")
|
||||||
|
|
||||||
|
const pemCert = ref<FileSelectorState>(null)
|
||||||
|
const pemKey = ref<FileSelectorState>(null)
|
||||||
|
|
||||||
|
const pfxCert = ref<FileSelectorState>(null)
|
||||||
|
|
||||||
|
const pfxPassword = ref("")
|
||||||
|
const showPfxPassword = ref(false)
|
||||||
|
|
||||||
|
const anyFileSelectorIsLoading = computed(
|
||||||
|
() =>
|
||||||
|
pemCert.value?.type === "loading" ||
|
||||||
|
pemKey.value?.type === "loading" ||
|
||||||
|
pfxCert.value?.type === "loading"
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentlyPickingFile = ref<null | "pem_cert" | "pem_key" | "pfx_cert">(
|
||||||
|
null
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedTab = ref<"pem" | "pfx">("pem")
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(show) => {
|
||||||
|
if (!show) return
|
||||||
|
|
||||||
|
currentlyPickingFile.value = null
|
||||||
|
|
||||||
|
domain.value = ""
|
||||||
|
pemCert.value = null
|
||||||
|
pemKey.value = null
|
||||||
|
pfxCert.value = null
|
||||||
|
pfxPassword.value = ""
|
||||||
|
showPfxPassword.value = false
|
||||||
|
selectedTab.value = "pem"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const certificate = computed<ClientCertificateEntry | null>(() => {
|
||||||
|
if (selectedTab.value === "pem") {
|
||||||
|
if (pemCert.value?.type === "loaded" && pemKey.value?.type === "loaded") {
|
||||||
|
return <ClientCertificateEntry>{
|
||||||
|
domain: domain.value,
|
||||||
|
enabled: true,
|
||||||
|
cert: {
|
||||||
|
PEMCert: {
|
||||||
|
certificate_filename: pemCert.value.filename,
|
||||||
|
certificate_pem: pemCert.value.data,
|
||||||
|
|
||||||
|
key_filename: pemKey.value.filename,
|
||||||
|
key_pem: pemKey.value.data,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (pfxCert.value?.type === "loaded") {
|
||||||
|
return <ClientCertificateEntry>{
|
||||||
|
domain: domain.value.trim(),
|
||||||
|
enabled: true,
|
||||||
|
cert: {
|
||||||
|
PFXCert: {
|
||||||
|
certificate_filename: pfxCert.value.filename,
|
||||||
|
certificate_pfx: pfxCert.value.data,
|
||||||
|
password: pfxPassword.value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
const isValidCertificate = computed(() => {
|
||||||
|
if (certificate.value === null) return false
|
||||||
|
|
||||||
|
if (props.existingDomains.includes(certificate.value.domain)) {
|
||||||
|
toast.error("A certificate for this domain already exists")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return ClientCertificateEntry.safeParse(certificate.value).success
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
open: openFileDialog,
|
||||||
|
reset: resetFilePicker,
|
||||||
|
onChange: onFilePickerChange,
|
||||||
|
} = useFileDialog({
|
||||||
|
reset: true,
|
||||||
|
multiple: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
onFilePickerChange(async (files) => {
|
||||||
|
if (!files) return
|
||||||
|
|
||||||
|
const file = files.item(0)
|
||||||
|
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
if (currentlyPickingFile.value === "pem_cert") {
|
||||||
|
pemCert.value = { type: "loading" }
|
||||||
|
} else if (currentlyPickingFile.value === "pem_key") {
|
||||||
|
pemKey.value = { type: "loading" }
|
||||||
|
} else if (currentlyPickingFile.value === "pfx_cert") {
|
||||||
|
pfxCert.value = { type: "loading" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = new Uint8Array(await file.arrayBuffer())
|
||||||
|
|
||||||
|
if (currentlyPickingFile.value === "pem_cert") {
|
||||||
|
pemCert.value = { type: "loaded", filename: file.name, data }
|
||||||
|
} else if (currentlyPickingFile.value === "pem_key") {
|
||||||
|
pemKey.value = { type: "loaded", filename: file.name, data }
|
||||||
|
} else if (currentlyPickingFile.value === "pfx_cert") {
|
||||||
|
pfxCert.value = { type: "loaded", filename: file.name, data }
|
||||||
|
}
|
||||||
|
|
||||||
|
currentlyPickingFile.value = null
|
||||||
|
|
||||||
|
resetFilePicker()
|
||||||
|
})
|
||||||
|
|
||||||
|
function openFilePicker(type: "pem_cert" | "pem_key" | "pfx_cert") {
|
||||||
|
currentlyPickingFile.value = type
|
||||||
|
|
||||||
|
openFileDialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
if (certificate.value) {
|
||||||
|
emit("save", certificate.value)
|
||||||
|
emit("hide-modal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<div class="py-4 space-y-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<HoppSmartToggle
|
||||||
|
:on="allowSSLVerification"
|
||||||
|
@change="allowSSLVerification = !allowSSLVerification"
|
||||||
|
/>
|
||||||
|
Verify SSL Certificates
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex space-x-4">
|
||||||
|
<!--
|
||||||
|
<HoppButtonSecondary
|
||||||
|
:icon="IconLucideFileBadge"
|
||||||
|
:label="'CA Certificates'"
|
||||||
|
outline
|
||||||
|
@click="showCACertificatesModal = true"
|
||||||
|
/>
|
||||||
|
-->
|
||||||
|
<HoppButtonSecondary
|
||||||
|
:icon="IconLucideFileKey"
|
||||||
|
:label="'Client Certificates'"
|
||||||
|
@click="showClientCertificatesModal = true"
|
||||||
|
outline
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
<ModalsNativeCACertificates
|
||||||
|
:show="showCACertificatesModal"
|
||||||
|
@hide-modal="showCACertificatesModal = false"
|
||||||
|
/>
|
||||||
|
-->
|
||||||
|
<ModalsNativeClientCertificates
|
||||||
|
:show="showClientCertificatesModal"
|
||||||
|
@hide-modal="showClientCertificatesModal = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- TODO: i18n -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue"
|
||||||
|
import IconLucideFileBadge from "~icons/lucide/file-badge"
|
||||||
|
import IconLucideFileKey from "~icons/lucide/file-key"
|
||||||
|
import { useService } from "dioc/vue"
|
||||||
|
import { NativeInterceptorService } from "@platform/interceptors/native"
|
||||||
|
|
||||||
|
const nativeInterceptorService = useService(NativeInterceptorService)
|
||||||
|
|
||||||
|
const allowSSLVerification = nativeInterceptorService.validateCerts
|
||||||
|
|
||||||
|
// const showCACertificatesModal = ref(false)
|
||||||
|
const showClientCertificatesModal = ref(false)
|
||||||
|
</script>
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
import * as E from "fp-ts/Either"
|
|
||||||
import {
|
|
||||||
Interceptor,
|
|
||||||
InterceptorError,
|
|
||||||
RequestRunResult,
|
|
||||||
} from "@hoppscotch/common/services/interceptor.service"
|
|
||||||
import { CookieJarService } from "@hoppscotch/common/services/cookie-jar.service"
|
|
||||||
import axios, { AxiosRequestConfig, CancelToken } from "axios"
|
|
||||||
import { cloneDeep } from "lodash-es"
|
|
||||||
import { Body, HttpVerb, ResponseType, getClient } from "@tauri-apps/api/http"
|
|
||||||
import { Service } from "dioc"
|
|
||||||
|
|
||||||
export const preProcessRequest = (
|
|
||||||
req: AxiosRequestConfig
|
|
||||||
): AxiosRequestConfig => {
|
|
||||||
const reqClone = cloneDeep(req)
|
|
||||||
|
|
||||||
// If the parameters are URLSearchParams, inject them to URL instead
|
|
||||||
// This prevents issues of marshalling the URLSearchParams to the proxy
|
|
||||||
if (reqClone.params instanceof URLSearchParams) {
|
|
||||||
try {
|
|
||||||
const url = new URL(reqClone.url ?? "")
|
|
||||||
|
|
||||||
for (const [key, value] of reqClone.params.entries()) {
|
|
||||||
url.searchParams.append(key, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
reqClone.url = url.toString()
|
|
||||||
} catch (e) {
|
|
||||||
// making this a non-empty block, so we can make the linter happy.
|
|
||||||
// we should probably use, allowEmptyCatch, or take the time to do something with the caught errors :)
|
|
||||||
}
|
|
||||||
|
|
||||||
reqClone.params = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
return reqClone
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runRequest(
|
|
||||||
req: AxiosRequestConfig,
|
|
||||||
cancelled: () => boolean
|
|
||||||
): RequestRunResult["response"] {
|
|
||||||
const timeStart = Date.now()
|
|
||||||
|
|
||||||
const processedReq = preProcessRequest(req)
|
|
||||||
try {
|
|
||||||
const client = await getClient()
|
|
||||||
|
|
||||||
if (cancelled()) {
|
|
||||||
client.drop()
|
|
||||||
return E.left("cancellation")
|
|
||||||
}
|
|
||||||
|
|
||||||
let body = Body.text(processedReq.data ?? "")
|
|
||||||
|
|
||||||
if (processedReq.data instanceof FormData) {
|
|
||||||
let body_data = {}
|
|
||||||
for (const entry of processedReq.data.entries()) {
|
|
||||||
const [name, value] = entry
|
|
||||||
|
|
||||||
if (value instanceof File) {
|
|
||||||
let file_data = await value.arrayBuffer()
|
|
||||||
|
|
||||||
body_data[name] = {
|
|
||||||
file: new Uint8Array(file_data),
|
|
||||||
fileName: value.name,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body = Body.form(body_data)
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await client.request({
|
|
||||||
method: processedReq.method as HttpVerb,
|
|
||||||
url: processedReq.url ?? "",
|
|
||||||
responseType: ResponseType.Binary,
|
|
||||||
headers: processedReq.headers,
|
|
||||||
body: body,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (cancelled()) {
|
|
||||||
client.drop()
|
|
||||||
return E.left("cancellation")
|
|
||||||
}
|
|
||||||
|
|
||||||
res.data = new Uint8Array(res.data as number[]).buffer
|
|
||||||
|
|
||||||
const timeEnd = Date.now()
|
|
||||||
|
|
||||||
return E.right({
|
|
||||||
...res,
|
|
||||||
config: {
|
|
||||||
timeData: {
|
|
||||||
startTime: timeStart,
|
|
||||||
endTime: timeEnd,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
additional: {
|
|
||||||
multiHeaders: Object.entries(res.rawHeaders).flatMap(
|
|
||||||
([header, values]) => values.map((value) => ({ key: header, value }))
|
|
||||||
),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
const timeEnd = Date.now()
|
|
||||||
|
|
||||||
if (axios.isAxiosError(e) && e.response) {
|
|
||||||
return E.right({
|
|
||||||
...e.response,
|
|
||||||
config: {
|
|
||||||
timeData: {
|
|
||||||
startTime: timeStart,
|
|
||||||
endTime: timeEnd,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (axios.isCancel(e)) {
|
|
||||||
return E.left("cancellation")
|
|
||||||
} else {
|
|
||||||
return E.left(<InterceptorError>{
|
|
||||||
humanMessage: {
|
|
||||||
heading: (t) => t("error.network_fail"),
|
|
||||||
description: (t) => t("helpers.network_fail"),
|
|
||||||
},
|
|
||||||
error: e,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class NativeInterceptorService extends Service implements Interceptor {
|
|
||||||
public static readonly ID = "NATIVE_INTERCEPTOR_SERVICE"
|
|
||||||
|
|
||||||
public interceptorID = "native" // TODO: i18n this
|
|
||||||
|
|
||||||
public name = () => "Native"
|
|
||||||
|
|
||||||
public selectable = { type: "selectable" as const }
|
|
||||||
|
|
||||||
public supportsCookies = true
|
|
||||||
|
|
||||||
public cookieJarService = this.bind(CookieJarService)
|
|
||||||
|
|
||||||
public runRequest(req: any) {
|
|
||||||
const processedReq = preProcessRequest(req)
|
|
||||||
|
|
||||||
const relevantCookies = this.cookieJarService.getCookiesForURL(
|
|
||||||
new URL(processedReq.url!)
|
|
||||||
)
|
|
||||||
|
|
||||||
processedReq.headers["Cookie"] = relevantCookies
|
|
||||||
.map((cookie) => `${cookie.name!}=${cookie.value!}`)
|
|
||||||
.join(";")
|
|
||||||
|
|
||||||
let cancelled = false
|
|
||||||
|
|
||||||
const checkCancelled = () => {
|
|
||||||
return cancelled
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
cancel: () => {
|
|
||||||
cancelled = true
|
|
||||||
},
|
|
||||||
response: runRequest(processedReq, checkCancelled),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
import { CookieJarService } from "@hoppscotch/common/services/cookie-jar.service"
|
||||||
|
import { Interceptor, InterceptorError, NetworkResponse, RequestRunResult } from "@hoppscotch/common/services/interceptor.service"
|
||||||
|
import { Service } from "dioc"
|
||||||
|
import { cloneDeep } from "lodash-es"
|
||||||
|
import { invoke } from "@tauri-apps/api/tauri"
|
||||||
|
import * as E from "fp-ts/Either"
|
||||||
|
import SettingsNativeInterceptor from "../../../components/settings/NativeInterceptor.vue"
|
||||||
|
import { ref, watch } from "vue"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { PersistenceService } from "@hoppscotch/common/services/persistence"
|
||||||
|
import { CACertStore, ClientCertsStore, ClientCertStore, StoredClientCert } from "./persisted-data"
|
||||||
|
|
||||||
|
|
||||||
|
type KeyValuePair = {
|
||||||
|
key: string,
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormDataValue =
|
||||||
|
| { Text: string }
|
||||||
|
| {
|
||||||
|
File: {
|
||||||
|
filename: string,
|
||||||
|
data: Uint8Array
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormDataEntry = {
|
||||||
|
key: string,
|
||||||
|
value: FormDataValue
|
||||||
|
}
|
||||||
|
|
||||||
|
type BodyDef =
|
||||||
|
| { Text: string }
|
||||||
|
| { URLEncoded: KeyValuePair[] }
|
||||||
|
| { FormData: FormDataEntry[] }
|
||||||
|
|
||||||
|
type ClientCertDef =
|
||||||
|
| {
|
||||||
|
PEMCert: {
|
||||||
|
certificate_pem: number[],
|
||||||
|
key_pem: number[]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
PFXCert: {
|
||||||
|
certificate_pfx: number[],
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Figure out a way to autogen this from the interceptor definition on the Rust side
|
||||||
|
type RequestDef = {
|
||||||
|
req_id: number
|
||||||
|
|
||||||
|
method: string
|
||||||
|
endpoint: string
|
||||||
|
|
||||||
|
parameters: KeyValuePair[]
|
||||||
|
headers: KeyValuePair[]
|
||||||
|
|
||||||
|
body: BodyDef | null,
|
||||||
|
|
||||||
|
validate_certs: boolean,
|
||||||
|
root_cert_bundle_files: number[],
|
||||||
|
client_cert: ClientCertDef | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type RunRequestResponse = {
|
||||||
|
status: number,
|
||||||
|
status_text: string,
|
||||||
|
headers: KeyValuePair[],
|
||||||
|
data: number[],
|
||||||
|
|
||||||
|
time_start_ms: number,
|
||||||
|
time_end_ms: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// HACK: To solve the AxiosRequestConfig being different between @hoppscotch/common
|
||||||
|
// and the axios present in this package
|
||||||
|
type AxiosRequestConfig = Parameters<Interceptor["runRequest"]>[0]
|
||||||
|
|
||||||
|
export const preProcessRequest = (
|
||||||
|
req: AxiosRequestConfig
|
||||||
|
): AxiosRequestConfig => {
|
||||||
|
const reqClone = cloneDeep(req)
|
||||||
|
|
||||||
|
// If the parameters are URLSearchParams, inject them to URL instead
|
||||||
|
// This prevents issues of marshalling the URLSearchParams to the proxy
|
||||||
|
if (reqClone.params instanceof URLSearchParams) {
|
||||||
|
try {
|
||||||
|
const url = new URL(reqClone.url ?? "")
|
||||||
|
|
||||||
|
for (const [key, value] of reqClone.params.entries()) {
|
||||||
|
url.searchParams.append(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
reqClone.url = url.toString()
|
||||||
|
} catch (e) {
|
||||||
|
// making this a non-empty block, so we can make the linter happy.
|
||||||
|
// we should probably use, allowEmptyCatch, or take the time to do something with the caught errors :)
|
||||||
|
}
|
||||||
|
|
||||||
|
reqClone.params = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reqClone
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processBody(axiosReq: AxiosRequestConfig): Promise<BodyDef | null> {
|
||||||
|
if (!axiosReq.data) return null
|
||||||
|
|
||||||
|
if (typeof axiosReq.data === "string") {
|
||||||
|
return { Text: axiosReq.data }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (axiosReq.data instanceof FormData) {
|
||||||
|
const entries: FormDataEntry[] = []
|
||||||
|
|
||||||
|
for (const [key, value] of axiosReq.data.entries()) {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
entries.push({
|
||||||
|
key,
|
||||||
|
value: { Text: value }
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
entries.push({
|
||||||
|
key,
|
||||||
|
value: {
|
||||||
|
File: {
|
||||||
|
filename: value.name,
|
||||||
|
data: new Uint8Array(await value.arrayBuffer())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { FormData: entries }
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Native Process Body: Unhandled Axios Request Configuration")
|
||||||
|
}
|
||||||
|
|
||||||
|
function getURLDomain(url: string): string | null {
|
||||||
|
try {
|
||||||
|
return new URL(url).host
|
||||||
|
} catch (_) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertClientCertToDefCert(cert: ClientCertificateEntry): ClientCertDef {
|
||||||
|
if ("PEMCert" in cert.cert) {
|
||||||
|
return {
|
||||||
|
PEMCert: {
|
||||||
|
certificate_pem: Array.from(cert.cert.PEMCert.certificate_pem),
|
||||||
|
key_pem: Array.from(cert.cert.PEMCert.key_pem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
PFXCert: {
|
||||||
|
certificate_pfx: Array.from(cert.cert.PFXCert.certificate_pfx),
|
||||||
|
password: cert.cert.PFXCert.password
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convertToRequestDef(
|
||||||
|
axiosReq: AxiosRequestConfig,
|
||||||
|
reqID: number,
|
||||||
|
caCertificates: CACertificateEntry[],
|
||||||
|
clientCertificates: Map<string, ClientCertificateEntry>,
|
||||||
|
validateCerts: boolean
|
||||||
|
): Promise<RequestDef> {
|
||||||
|
const clientCertDomain = getURLDomain(axiosReq.url!)
|
||||||
|
|
||||||
|
const clientCert = clientCertDomain ? clientCertificates.get(clientCertDomain) : null
|
||||||
|
|
||||||
|
return {
|
||||||
|
req_id: reqID,
|
||||||
|
method: axiosReq.method ?? "GET",
|
||||||
|
endpoint: axiosReq.url ?? "",
|
||||||
|
headers: Object.entries(axiosReq.headers ?? {})
|
||||||
|
.map(([key, value]): KeyValuePair => ({ key, value })),
|
||||||
|
parameters: Object.entries(axiosReq.params as Record<string, string> ?? {})
|
||||||
|
.map(([key, value]): KeyValuePair => ({ key, value })),
|
||||||
|
body: await processBody(axiosReq),
|
||||||
|
root_cert_bundle_files: caCertificates.map((cert) => Array.from(cert.certificate)),
|
||||||
|
validate_certs: validateCerts,
|
||||||
|
client_cert: clientCert ? convertClientCertToDefCert(clientCert) : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CACertificateEntry = z.object({
|
||||||
|
filename: z.string().min(1),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
certificate: z.instanceof(Uint8Array)
|
||||||
|
})
|
||||||
|
|
||||||
|
export type CACertificateEntry = z.infer<typeof CACertificateEntry>
|
||||||
|
|
||||||
|
export const ClientCertificateEntry = z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
domain: z.string().trim().min(1),
|
||||||
|
cert: z.union([
|
||||||
|
z.object({
|
||||||
|
PEMCert: z.object({
|
||||||
|
certificate_filename: z.string().min(1),
|
||||||
|
certificate_pem: z.instanceof(Uint8Array),
|
||||||
|
|
||||||
|
key_filename: z.string().min(1),
|
||||||
|
key_pem: z.instanceof(Uint8Array),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
PFXCert: z.object({
|
||||||
|
certificate_filename: z.string().min(1),
|
||||||
|
certificate_pfx: z.instanceof(Uint8Array),
|
||||||
|
|
||||||
|
password: z.string()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ClientCertificateEntry = z.infer<typeof ClientCertificateEntry>
|
||||||
|
|
||||||
|
const CA_STORE_PERSIST_KEY = "native_interceptor_ca_store"
|
||||||
|
const CLIENT_CERTS_PERSIST_KEY = "native_interceptor_client_certs_store"
|
||||||
|
const VALIDATE_SSL_KEY = "native_interceptor_validate_ssl"
|
||||||
|
|
||||||
|
export class NativeInterceptorService extends Service implements Interceptor {
|
||||||
|
public static readonly ID = "NATIVE_INTERCEPTOR_SERVICE"
|
||||||
|
|
||||||
|
public interceptorID = "native"
|
||||||
|
|
||||||
|
public name = () => "Native"
|
||||||
|
|
||||||
|
public selectable = { type: "selectable" as const }
|
||||||
|
|
||||||
|
public supportsCookies = true
|
||||||
|
|
||||||
|
private cookieJarService = this.bind(CookieJarService)
|
||||||
|
private persistenceService: PersistenceService = this.bind(PersistenceService)
|
||||||
|
|
||||||
|
private reqIDTicker = 0
|
||||||
|
|
||||||
|
public settingsPageEntry = {
|
||||||
|
entryTitle: () => "Native", // TODO: i18n this
|
||||||
|
component: SettingsNativeInterceptor
|
||||||
|
}
|
||||||
|
|
||||||
|
public caCertificates = ref<CACertificateEntry[]>([])
|
||||||
|
|
||||||
|
public clientCertificates = ref<Map<string, ClientCertificateEntry>>(new Map())
|
||||||
|
public validateCerts = ref(true)
|
||||||
|
|
||||||
|
override onServiceInit() {
|
||||||
|
// Load SSL Validation
|
||||||
|
const persistedValidateSSL: unknown = JSON.parse(
|
||||||
|
this.persistenceService.getLocalConfig(VALIDATE_SSL_KEY) ?? "null"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (typeof persistedValidateSSL === "boolean") {
|
||||||
|
this.validateCerts.value = persistedValidateSSL
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(this.validateCerts, () => {
|
||||||
|
this.persistenceService.setLocalConfig(VALIDATE_SSL_KEY, JSON.stringify(this.validateCerts.value))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Load and setup writes for CA Store
|
||||||
|
const persistedCAStoreData = JSON.parse(
|
||||||
|
this.persistenceService.getLocalConfig(CA_STORE_PERSIST_KEY) ?? "null"
|
||||||
|
)
|
||||||
|
|
||||||
|
const caStoreDataParseResult = CACertStore.safeParse(persistedCAStoreData)
|
||||||
|
|
||||||
|
if (caStoreDataParseResult.type === "ok") {
|
||||||
|
this.caCertificates.value = caStoreDataParseResult.value.certs
|
||||||
|
.map((entry) => ({ ...entry, certificate: new Uint8Array(entry.certificate) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(this.caCertificates, (certs) => {
|
||||||
|
const storableValue: CACertStore = {
|
||||||
|
v: 1,
|
||||||
|
certs: certs
|
||||||
|
.map((el) => ({ ...el, certificate: Array.from(el.certificate) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
this.persistenceService.setLocalConfig(CA_STORE_PERSIST_KEY, JSON.stringify(storableValue))
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
// Load and setup writes for Client Certs Store
|
||||||
|
const persistedClientCertStoreData = JSON.parse(
|
||||||
|
this.persistenceService.getLocalConfig(CLIENT_CERTS_PERSIST_KEY) ?? "null"
|
||||||
|
)
|
||||||
|
|
||||||
|
const clientCertStoreDataParseResult = ClientCertsStore.safeParse(persistedClientCertStoreData)
|
||||||
|
|
||||||
|
if (clientCertStoreDataParseResult.type === "ok") {
|
||||||
|
this.clientCertificates.value = new Map(
|
||||||
|
Object.entries(
|
||||||
|
clientCertStoreDataParseResult.value.clientCerts
|
||||||
|
)
|
||||||
|
.map(([domain, cert]) => {
|
||||||
|
if ("PFXCert" in cert.cert) {
|
||||||
|
const newCert = <ClientCertificateEntry>{
|
||||||
|
...cert,
|
||||||
|
cert: {
|
||||||
|
PFXCert: {
|
||||||
|
certificate_pfx: new Uint8Array(cert.cert.PFXCert.certificate_pfx),
|
||||||
|
certificate_filename: cert.cert.PFXCert.certificate_filename,
|
||||||
|
|
||||||
|
password: cert.cert.PFXCert.password
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [domain, newCert]
|
||||||
|
} else {
|
||||||
|
const newCert = <ClientCertificateEntry>{
|
||||||
|
...cert,
|
||||||
|
cert: {
|
||||||
|
PEMCert: {
|
||||||
|
certificate_pem: new Uint8Array(cert.cert.PEMCert.certificate_pem),
|
||||||
|
certificate_filename: cert.cert.PEMCert.certificate_filename,
|
||||||
|
|
||||||
|
key_pem: new Uint8Array(cert.cert.PEMCert.key_pem),
|
||||||
|
key_filename: cert.cert.PEMCert.key_filename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [domain, newCert]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(this.clientCertificates, (certs) => {
|
||||||
|
const storableValue: ClientCertStore = {
|
||||||
|
v: 1,
|
||||||
|
clientCerts: Object.fromEntries(
|
||||||
|
Array.from(certs.entries())
|
||||||
|
.map(([domain, cert]) => {
|
||||||
|
if ("PFXCert" in cert.cert) {
|
||||||
|
const newCert = <StoredClientCert>{
|
||||||
|
...cert,
|
||||||
|
cert: {
|
||||||
|
PFXCert: {
|
||||||
|
certificate_pfx: Array.from(cert.cert.PFXCert.certificate_pfx),
|
||||||
|
certificate_filename: cert.cert.PFXCert.certificate_filename,
|
||||||
|
|
||||||
|
password: cert.cert.PFXCert.password
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [domain, newCert]
|
||||||
|
} else {
|
||||||
|
const newCert = <StoredClientCert>{
|
||||||
|
...cert,
|
||||||
|
cert: {
|
||||||
|
PEMCert: {
|
||||||
|
certificate_pem: Array.from(cert.cert.PEMCert.certificate_pem),
|
||||||
|
certificate_filename: cert.cert.PEMCert.certificate_filename,
|
||||||
|
|
||||||
|
key_pem: Array.from(cert.cert.PEMCert.key_pem),
|
||||||
|
key_filename: cert.cert.PEMCert.key_filename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [domain, newCert]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
this.persistenceService.setLocalConfig(CLIENT_CERTS_PERSIST_KEY, JSON.stringify(storableValue))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public runRequest(req: AxiosRequestConfig): RequestRunResult<InterceptorError> {
|
||||||
|
const processedReq = preProcessRequest(req)
|
||||||
|
|
||||||
|
const relevantCookies = this.cookieJarService.getCookiesForURL(
|
||||||
|
new URL(processedReq.url!)
|
||||||
|
)
|
||||||
|
|
||||||
|
processedReq.headers["Cookie"] = relevantCookies
|
||||||
|
.map((cookie) => `${cookie.name!}=${cookie.value!}`)
|
||||||
|
.join(";")
|
||||||
|
|
||||||
|
const reqID = this.reqIDTicker++;
|
||||||
|
|
||||||
|
return {
|
||||||
|
cancel: () => {
|
||||||
|
invoke("plugin:hopp_native_interceptor|cancel_request", { reqId: reqID });
|
||||||
|
},
|
||||||
|
response: (async () => {
|
||||||
|
const requestDef = await convertToRequestDef(
|
||||||
|
processedReq,
|
||||||
|
reqID,
|
||||||
|
this.caCertificates.value,
|
||||||
|
this.clientCertificates.value,
|
||||||
|
this.validateCerts.value
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(requestDef)
|
||||||
|
|
||||||
|
const response: RunRequestResponse = await invoke(
|
||||||
|
"plugin:hopp_native_interceptor|run_request",
|
||||||
|
{ req: requestDef }
|
||||||
|
)
|
||||||
|
|
||||||
|
return E.right({
|
||||||
|
headers: Object.fromEntries(
|
||||||
|
response.headers.map(({ key, value }) => [key, value])
|
||||||
|
),
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.status_text,
|
||||||
|
data: new Uint8Array(response.data).buffer,
|
||||||
|
config: {
|
||||||
|
timeData: {
|
||||||
|
startTime: response.time_start_ms,
|
||||||
|
endTime: response.time_end_ms
|
||||||
|
}
|
||||||
|
},
|
||||||
|
additional: {
|
||||||
|
multiHeaders: response.headers
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e)
|
||||||
|
|
||||||
|
if (typeof e === "object" && (e as any)["RequestCancelled"]) {
|
||||||
|
return E.left("cancellation" as const)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: More in-depth error messages
|
||||||
|
return E.left(<InterceptorError>{
|
||||||
|
humanMessage: {
|
||||||
|
heading: (t) => t("error.network_fail"),
|
||||||
|
description: (t) => t("helpers.network_fail"),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
import { defineVersion, createVersionedEntity, InferredEntity } from "verzod"
|
||||||
|
|
||||||
|
const Uint8 = z.number()
|
||||||
|
.int()
|
||||||
|
.gte(0)
|
||||||
|
.lte(255);
|
||||||
|
|
||||||
|
export const StoredCACert = z.object({
|
||||||
|
filename: z.string().min(1),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
certificate: z.array(Uint8)
|
||||||
|
})
|
||||||
|
|
||||||
|
const caCertStore_v1 = defineVersion({
|
||||||
|
initial: true,
|
||||||
|
schema: z.object({
|
||||||
|
v: z.literal(1),
|
||||||
|
certs: z.array(StoredCACert)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
export const CACertStore = createVersionedEntity({
|
||||||
|
latestVersion: 1,
|
||||||
|
versionMap: {
|
||||||
|
1: caCertStore_v1
|
||||||
|
},
|
||||||
|
getVersion(data) {
|
||||||
|
const result = caCertStore_v1.schema.safeParse(data)
|
||||||
|
|
||||||
|
return result.success ? result.data.v : null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export type CACertStore = InferredEntity<typeof CACertStore>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const StoredClientCert = z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
domain: z.string().trim().min(1),
|
||||||
|
cert: z.union([
|
||||||
|
z.object({
|
||||||
|
PEMCert: z.object({
|
||||||
|
certificate_filename: z.string().min(1),
|
||||||
|
certificate_pem: z.array(Uint8),
|
||||||
|
|
||||||
|
key_filename: z.string().min(1),
|
||||||
|
key_pem: z.array(Uint8)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
PFXCert: z.object({
|
||||||
|
certificate_filename: z.string().min(1),
|
||||||
|
certificate_pfx: z.array(Uint8),
|
||||||
|
|
||||||
|
password: z.string()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
export type StoredClientCert = z.infer<typeof StoredClientCert>
|
||||||
|
|
||||||
|
const clientCertsStore_v1 = defineVersion({
|
||||||
|
initial: true,
|
||||||
|
schema: z.object({
|
||||||
|
v: z.literal(1),
|
||||||
|
clientCerts: z.record(StoredClientCert)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ClientCertsStore = createVersionedEntity({
|
||||||
|
latestVersion: 1,
|
||||||
|
versionMap: {
|
||||||
|
1: clientCertsStore_v1
|
||||||
|
},
|
||||||
|
getVersion(data) {
|
||||||
|
const result = clientCertsStore_v1.schema.safeParse(data)
|
||||||
|
|
||||||
|
return result.success ? result.data.v : null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ClientCertStore = InferredEntity<typeof ClientCertsStore>
|
||||||
@@ -108,6 +108,7 @@ export default defineConfig({
|
|||||||
dts: "../hoppscotch-common/src/components.d.ts",
|
dts: "../hoppscotch-common/src/components.d.ts",
|
||||||
dirs: [
|
dirs: [
|
||||||
"../hoppscotch-common/src/components",
|
"../hoppscotch-common/src/components",
|
||||||
|
"./src/components"
|
||||||
],
|
],
|
||||||
directoryAsNamespace: true,
|
directoryAsNamespace: true,
|
||||||
resolvers: [
|
resolvers: [
|
||||||
|
|||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -964,12 +964,18 @@ importers:
|
|||||||
util:
|
util:
|
||||||
specifier: 0.12.5
|
specifier: 0.12.5
|
||||||
version: 0.12.5
|
version: 0.12.5
|
||||||
|
verzod:
|
||||||
|
specifier: 0.2.2
|
||||||
|
version: 0.2.2(zod@3.22.4)
|
||||||
vue:
|
vue:
|
||||||
specifier: 3.3.9
|
specifier: 3.3.9
|
||||||
version: 3.3.9(typescript@4.9.5)
|
version: 3.3.9(typescript@4.9.5)
|
||||||
workbox-window:
|
workbox-window:
|
||||||
specifier: 6.6.0
|
specifier: 6.6.0
|
||||||
version: 6.6.0
|
version: 6.6.0
|
||||||
|
zod:
|
||||||
|
specifier: 3.22.4
|
||||||
|
version: 3.22.4
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@graphql-codegen/add':
|
'@graphql-codegen/add':
|
||||||
specifier: 5.0.0
|
specifier: 5.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user