From 4fc95d264caedf9b3aa40a88e590814c14379c71 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Mon, 9 Nov 2026 11:00:58 -0700 Subject: [PATCH] Clear Yaak Proxy traffic at launch and follow the real system proxy Captured traffic is now cleared as the app launches by default, with a "Keep between launches" choice that brings the size limit with it. An install that had a size limit set keeps its traffic. The Proxy settings tab is regrouped into Proxy, Decryption, Traffic and Terminal, and the per-body limit row is gone. The app now watches macOS proxy settings and follows them: when another app or the user points the system proxy elsewhere, the override shows as off and the settings saved for restoring are dropped, so quitting puts back nothing the app didn't set. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 + apps/yaak-proxy/components/SettingsProxy.tsx | 91 +++--- apps/yaak-proxy/harness/LockedHarness.tsx | 1 + apps/yaak-proxy/harness/backend.ts | 1 + apps/yaak-proxy/lib/settings.test.ts | 29 +- apps/yaak-proxy/lib/settings.ts | 6 - crates-proxy/yaak-proxy-lib/Cargo.toml | 3 + .../yaak-proxy-lib/bindings/gen_rpc.ts | 16 +- crates-proxy/yaak-proxy-lib/src/db.rs | 15 + crates-proxy/yaak-proxy-lib/src/flows.rs | 21 ++ crates-proxy/yaak-proxy-lib/src/lib.rs | 272 +++++++++++++++++- crates-proxy/yaak-proxy-lib/src/macos.rs | 222 ++++++++++++++ crates-proxy/yaak-proxy-lib/src/settings.rs | 79 ++++- crates-proxy/yaak-proxy-lib/tests/at_rest.rs | 43 ++- crates-proxy/yaak-proxy-lib/tests/flow.rs | 1 + 15 files changed, 726 insertions(+), 76 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 73bdd8026..979e3c573 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11927,6 +11927,7 @@ dependencies = [ "base64 0.23.1", "bytes", "chrono", + "core-foundation 0.9.4", "encoding_rs", "flate2", "futures-util", @@ -11949,6 +11950,7 @@ dependencies = [ "sea-query", "serde", "serde_json", + "system-configuration", "tempfile", "tokio", "tonic", diff --git a/apps/yaak-proxy/components/SettingsProxy.tsx b/apps/yaak-proxy/components/SettingsProxy.tsx index 5ed520827..fdc785a73 100644 --- a/apps/yaak-proxy/components/SettingsProxy.tsx +++ b/apps/yaak-proxy/components/SettingsProxy.tsx @@ -1,6 +1,10 @@ import { useQueryClient } from "@tanstack/react-query"; import { formatSize } from "@yaakapp-internal/lib/formatSize"; -import type { HttpVersion, SystemProxyStatus } from "@yaakapp-internal/proxy-lib"; +import type { + HttpVersion, + SystemProxyStatus, + TrafficRetention, +} from "@yaakapp-internal/proxy-lib"; import { Button, Dialog, @@ -31,7 +35,6 @@ import { type TerminalShell, } from "../lib/env"; import { - CAPTURE_LIMIT_OPTIONS, KEEP_TRAFFIC_OPTIONS, sizeFromOptionValue, sizeOptionsFor, @@ -44,16 +47,15 @@ import { useShowToast } from "../lib/toast"; import { DecryptListEditor } from "./DecryptListEditor"; /** - * The proxy's own settings tab: how it listens, what it decrypts, the system proxy and its - * helper, how much traffic it keeps, and the environment for one terminal. Everything set once - * and left alone, which is what left the state pill's menu. + * The proxy's own settings tab: how it listens and the system proxy, what it decrypts, what + * happens to captured traffic, and the environment for one terminal. Everything set once and + * left alone, which is what left the state pill's menu. */ export function SettingsProxy() { const proxyState = useAtomValue(proxyStateAtom); const store = useStore(); const refresh = { onSettled: () => void refreshProxyState(store) }; const setHttpVersion = useRpcMutation("set_http_version", refresh); - const setCaptureLimit = useRpcMutation("set_capture_body_limit", refresh); const setAtLaunch = useRpcMutation("set_system_proxy_at_launch", refresh); const systemProxy = useRpcQuery("system_proxy_status", {}); // Another process owning the engine wouldn't see changes until it restarts @@ -63,10 +65,10 @@ export function SettingsProxy() {
Proxy -

Listening, decryption, storage and terminals

+

Listening, decryption, traffic and terminals

- + name="httpVersion" @@ -80,40 +82,27 @@ export function SettingsProxy() { { label: "HTTP/1.1 only", value: "http1Only" }, ]} /> - { - const bytes = sizeFromOptionValue(v); - if (bytes != null) setCaptureLimit.mutate({ bytes }); - }} - options={sizeOptionsFor(CAPTURE_LIMIT_OPTIONS, proxyState?.captureBodyLimit ?? null) - .filter((o) => o.bytes != null) - .map(({ label, value }) => ({ label, value }))} - /> + {systemProxy.data?.supported === true && ( + <> + setAtLaunch.mutate({ enabled })} + /> + + + )} - + - {systemProxy.data?.supported === true && ( - - setAtLaunch.mutate({ enabled })} - /> - - - )} - - - + + + {proxyState?.traffic === "keep" && } @@ -225,6 +214,30 @@ function HelperRow({ status }: { status: SystemProxyStatus }) { ); } +/** Whether captured traffic is cleared as the app launches or kept between launches */ +function TrafficRow() { + const proxyState = useAtomValue(proxyStateAtom); + const store = useStore(); + const setTraffic = useRpcMutation("set_traffic", { + onSettled: () => void refreshProxyState(store), + }); + return ( + + name="traffic" + title="Captured traffic" + selectClassName="w-56!" + value={proxyState?.traffic ?? "clearOnLaunch"} + disabled={proxyState?.otherOwner != null} + onChange={(traffic) => setTraffic.mutate({ traffic })} + options={[ + { label: "Clear on launch", value: "clearOnLaunch" }, + { label: "Keep between launches", value: "keep" }, + ]} + /> + ); +} + +/** The most traffic kept between launches, beside what is stored now */ function KeepTrafficRow() { const proxyState = useAtomValue(proxyStateAtom); const store = useStore(); @@ -234,7 +247,7 @@ function KeepTrafficRow() { }); const keep = proxyState?.keepTrafficBytes ?? null; return ( - +
{size.data != null && ( @@ -243,7 +256,7 @@ function KeepTrafficRow() { )} setKeep.mutate({ bytes: sizeFromOptionValue(v) })} diff --git a/apps/yaak-proxy/harness/LockedHarness.tsx b/apps/yaak-proxy/harness/LockedHarness.tsx index f8c77a071..7c0b2e4eb 100644 --- a/apps/yaak-proxy/harness/LockedHarness.tsx +++ b/apps/yaak-proxy/harness/LockedHarness.tsx @@ -23,6 +23,7 @@ const STATE: GetProxyStateResponse = { locked: { message: LOCKED, keychain: true }, otherOwner: null, captureBodyLimit: 10 * 1024 * 1024, + traffic: "clearOnLaunch", keepTrafficBytes: null, }; diff --git a/apps/yaak-proxy/harness/backend.ts b/apps/yaak-proxy/harness/backend.ts index 701f2091f..391268241 100644 --- a/apps/yaak-proxy/harness/backend.ts +++ b/apps/yaak-proxy/harness/backend.ts @@ -31,6 +31,7 @@ export const HARNESS_STATE: GetProxyStateResponse = { systemProxyAtLaunch: true, httpVersion: "auto", captureBodyLimit: 10 * 1024 * 1024, + traffic: "keep", keepTrafficBytes: 2 * 1024 * 1024 * 1024, startError: null, encrypting: null, diff --git a/apps/yaak-proxy/lib/settings.test.ts b/apps/yaak-proxy/lib/settings.test.ts index 158c7dea7..aa892b73b 100644 --- a/apps/yaak-proxy/lib/settings.test.ts +++ b/apps/yaak-proxy/lib/settings.test.ts @@ -1,6 +1,5 @@ import { describe, expect, test } from "vite-plus/test"; import { - CAPTURE_LIMIT_OPTIONS, DEFAULT_SETTINGS_VALUES, KEEP_TRAFFIC_OPTIONS, proxySettingsView, @@ -78,13 +77,6 @@ describe("size options", () => { }); test("labels in the units the limits are set in", () => { - expect(CAPTURE_LIMIT_OPTIONS.map((o) => o.label)).toEqual([ - "1 MB", - "5 MB", - "10 MB", - "50 MB", - "100 MB", - ]); expect(KEEP_TRAFFIC_OPTIONS.map((o) => o.label)).toEqual([ "Unlimited", "500 MB", @@ -97,22 +89,25 @@ describe("size options", () => { }); test("values round trip", () => { - for (const o of [...KEEP_TRAFFIC_OPTIONS, ...CAPTURE_LIMIT_OPTIONS]) { + for (const o of KEEP_TRAFFIC_OPTIONS) { expect(sizeFromOptionValue(sizeOptionValue(o.bytes))).toBe(o.bytes); } expect(sizeFromOptionValue("abc")).toBeNull(); }); test("a size set elsewhere gets its own option, in order", () => { - const options = sizeOptionsFor(CAPTURE_LIMIT_OPTIONS, 3 * 1024 * 1024); + const MB = 1024 * 1024; + const options = sizeOptionsFor(KEEP_TRAFFIC_OPTIONS, 750 * MB); expect(options.map((o) => o.bytes)).toEqual([ - 1024 * 1024, - 3 * 1024 * 1024, - 5 * 1024 * 1024, - 10 * 1024 * 1024, - 50 * 1024 * 1024, - 100 * 1024 * 1024, + 500 * MB, + 750 * MB, + 1024 * MB, + 2048 * MB, + 5120 * MB, + 10240 * MB, + 20480 * MB, + null, ]); - expect(sizeOptionsFor(CAPTURE_LIMIT_OPTIONS, 10 * 1024 * 1024)).toBe(CAPTURE_LIMIT_OPTIONS); + expect(sizeOptionsFor(KEEP_TRAFFIC_OPTIONS, 1024 * MB)).toBe(KEEP_TRAFFIC_OPTIONS); }); }); diff --git a/apps/yaak-proxy/lib/settings.ts b/apps/yaak-proxy/lib/settings.ts index 7c22db897..117d83ad9 100644 --- a/apps/yaak-proxy/lib/settings.ts +++ b/apps/yaak-proxy/lib/settings.ts @@ -141,12 +141,6 @@ export const KEEP_TRAFFIC_OPTIONS = sizeOptions( "Unlimited", ); -/** How much of each body is stored */ -export const CAPTURE_LIMIT_OPTIONS = sizeOptions( - [MB, 5 * MB, 10 * MB, 50 * MB, 100 * MB], - "Unlimited", -); - /** * The option a stored size selects. A size set elsewhere (the CLI or `settings.json`) that no * option has is added as its own, so the select still shows it. diff --git a/crates-proxy/yaak-proxy-lib/Cargo.toml b/crates-proxy/yaak-proxy-lib/Cargo.toml index b0112ecb5..ccac0f160 100644 --- a/crates-proxy/yaak-proxy-lib/Cargo.toml +++ b/crates-proxy/yaak-proxy-lib/Cargo.toml @@ -71,6 +71,9 @@ objc2-service-management = { version = "0.3.2", default-features = false, featur "SMAppService", ] } plist = "1.7" +# Told when the system proxy changes underneath the app, as Proxyman or System Settings change it. +core-foundation = "0.9" +system-configuration = "0.7" [dev-dependencies] base64 = "0.23.1" diff --git a/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts b/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts index d94a860b5..c97e3c210 100644 --- a/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts +++ b/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts @@ -262,7 +262,12 @@ httpVersion: HttpVersion, */ captureBodyLimit: number, /** - * The most captured traffic kept, in bytes, or null to keep everything. + * Captured traffic is cleared at launch or kept between launches. + */ +traffic: TrafficRetention, +/** + * The most captured traffic kept, in bytes, or null to keep everything. Applies only + * while traffic is kept. */ keepTrafficBytes: number | null, /** @@ -447,7 +452,7 @@ export type ResetCapturedDataRequest = Record; export type RpcEventSchema = { decrypt_list_changed: DecryptListChangedPayload, flows_cleared: FlowsClearedPayload, flows_removed: FlowsRemovedPayload, message_write: MessagePayload, model_write: ModelPayload, proxy_state_changed: ProxyStatePayload, saved_queries_changed: SavedQueriesChangedPayload, }; -export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], get_decrypt_list: [GetDecryptListRequest, DecryptList], get_message_payload: [GetMessagePayloadRequest, GetMessagePayloadResponse], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], query_schema: [QuerySchemaRequest, QuerySchemaResponse], validate_query: [ValidateQueryRequest, Array], watch_query: [WatchQueryRequest, boolean], list_saved_queries: [ListSavedQueriesRequest, Array], create_saved_query: [CreateSavedQueryRequest, SavedQuery], rename_saved_query: [RenameSavedQueryRequest, SavedQuery], delete_saved_query: [DeleteSavedQueryRequest, boolean], list_grpc_schemas: [ListGrpcSchemasRequest, Array], list_messages: [ListMessagesRequest, ListMessagesResponse], remove_grpc_schema: [RemoveGrpcSchemaRequest, boolean], set_recording: [SetRecordingRequest, boolean], set_system_proxy_at_launch: [SetSystemProxyAtLaunchRequest, boolean], set_http_version: [SetHttpVersionRequest, HttpVersion], set_capture_body_limit: [SetCaptureBodyLimitRequest, number], set_keep_traffic: [SetKeepTrafficRequest, number | null], add_grpc_schema: [AddGrpcSchemaRequest, GrpcSchema], app_icon: [AppIconRequest, string | null], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaStatus], clear_flows: [ClearFlowsRequest, number], decode_grpc_message: [DecodeGrpcMessageRequest, DecodedGrpcMessage], export_har: [ExportHarRequest, number], filter_body: [FilterBodyRequest, FilterBodyResponse], get_flow_body: [GetFlowBodyRequest, GetFlowBodyResponse], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], helper_register: [HelperRegisterRequest, SystemProxyStatus], helper_unregister: [HelperUnregisterRequest, SystemProxyStatus], edit_decrypt_list: [EditDecryptListRequest, DecryptList], list_seen: [ListSeenRequest, SeenTargets], set_decrypt_list: [SetDecryptListRequest, DecryptList], list_workspaces: [ListWorkspacesRequest, ListWorkspacesResponse], preview_save: [PreviewSaveRequest, SavePreview], reflect_grpc_host: [ReflectGrpcHostRequest, GrpcSchema], save_flow: [SaveFlowToYaakRequest, SavedFlow], save_flow_body: [SaveFlowBodyRequest, SaveFlowBodyResponse], system_proxy_set: [SystemProxySetRequest, SystemProxyStatus], system_proxy_status: [SystemProxyStatusRequest, SystemProxyStatus], take_over: [TakeOverRequest, boolean], set_port: [SetPortRequest, number], get_storage_size: [GetStorageSizeRequest, GetStorageSizeResponse], reset_captured_data: [ResetCapturedDataRequest, boolean], open_terminal: [OpenTerminalRequest, boolean], query_values: [QueryValuesRequest, Array], match_flows: [MatchFlowsRequest, Array], count_saved_queries: [CountSavedQueriesRequest, Array], }; +export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], get_decrypt_list: [GetDecryptListRequest, DecryptList], get_message_payload: [GetMessagePayloadRequest, GetMessagePayloadResponse], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], query_schema: [QuerySchemaRequest, QuerySchemaResponse], validate_query: [ValidateQueryRequest, Array], watch_query: [WatchQueryRequest, boolean], list_saved_queries: [ListSavedQueriesRequest, Array], create_saved_query: [CreateSavedQueryRequest, SavedQuery], rename_saved_query: [RenameSavedQueryRequest, SavedQuery], delete_saved_query: [DeleteSavedQueryRequest, boolean], list_grpc_schemas: [ListGrpcSchemasRequest, Array], list_messages: [ListMessagesRequest, ListMessagesResponse], remove_grpc_schema: [RemoveGrpcSchemaRequest, boolean], set_recording: [SetRecordingRequest, boolean], set_system_proxy_at_launch: [SetSystemProxyAtLaunchRequest, boolean], set_http_version: [SetHttpVersionRequest, HttpVersion], set_capture_body_limit: [SetCaptureBodyLimitRequest, number], set_traffic: [SetTrafficRequest, TrafficRetention], set_keep_traffic: [SetKeepTrafficRequest, number | null], add_grpc_schema: [AddGrpcSchemaRequest, GrpcSchema], app_icon: [AppIconRequest, string | null], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaStatus], clear_flows: [ClearFlowsRequest, number], decode_grpc_message: [DecodeGrpcMessageRequest, DecodedGrpcMessage], export_har: [ExportHarRequest, number], filter_body: [FilterBodyRequest, FilterBodyResponse], get_flow_body: [GetFlowBodyRequest, GetFlowBodyResponse], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], helper_register: [HelperRegisterRequest, SystemProxyStatus], helper_unregister: [HelperUnregisterRequest, SystemProxyStatus], edit_decrypt_list: [EditDecryptListRequest, DecryptList], list_seen: [ListSeenRequest, SeenTargets], set_decrypt_list: [SetDecryptListRequest, DecryptList], list_workspaces: [ListWorkspacesRequest, ListWorkspacesResponse], preview_save: [PreviewSaveRequest, SavePreview], reflect_grpc_host: [ReflectGrpcHostRequest, GrpcSchema], save_flow: [SaveFlowToYaakRequest, SavedFlow], save_flow_body: [SaveFlowBodyRequest, SaveFlowBodyResponse], system_proxy_set: [SystemProxySetRequest, SystemProxyStatus], system_proxy_status: [SystemProxyStatusRequest, SystemProxyStatus], take_over: [TakeOverRequest, boolean], set_port: [SetPortRequest, number], get_storage_size: [GetStorageSizeRequest, GetStorageSizeResponse], reset_captured_data: [ResetCapturedDataRequest, boolean], open_terminal: [OpenTerminalRequest, boolean], query_values: [QueryValuesRequest, Array], match_flows: [MatchFlowsRequest, Array], count_saved_queries: [CountSavedQueriesRequest, Array], }; export type SaveFlowBodyRequest = { flowId: string, which: FlowBody, /** @@ -567,6 +572,8 @@ export type SetRecordingRequest = { enabled: boolean, }; export type SetSystemProxyAtLaunchRequest = { enabled: boolean, }; +export type SetTrafficRequest = { traffic: TrafficRetention, }; + /** * How a change gets administrator rights. */ @@ -596,6 +603,11 @@ export type SystemProxyStatusRequest = Record; export type TakeOverRequest = Record; +/** + * What happens to captured traffic between launches. + */ +export type TrafficRetention = "clearOnLaunch" | "keep"; + export type ValidateQueryRequest = { query: string, }; /** diff --git a/crates-proxy/yaak-proxy-lib/src/db.rs b/crates-proxy/yaak-proxy-lib/src/db.rs index 91b96581e..9c5ab4530 100644 --- a/crates-proxy/yaak-proxy-lib/src/db.rs +++ b/crates-proxy/yaak-proxy-lib/src/db.rs @@ -270,6 +270,21 @@ pub fn remove_captured(data_dir: &Path) -> std::result::Result<(), String> { Ok(()) } +/// Deletes `blobs.sqlite`, every body and message payload, for one made empty as it opens. +/// The data key stays, as `proxy.db` is still encrypted with it. Nothing may have it open. +pub fn remove_blobs(data_dir: &Path) -> std::result::Result<(), String> { + for suffix in ["", "-wal", "-shm"] { + let path = data_dir.join(format!("{BLOBS_DB}{suffix}")); + match std::fs::remove_file(&path) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => { + return Err(format!("Failed to delete {}: {e}", path.display())); + } + _ => {} + } + } + Ok(()) +} + /// Either database in `data_dir` is plain, left by a build from before encryption. pub fn needs_encrypting(data_dir: &Path) -> bool { [PROXY_DB, BLOBS_DB] diff --git a/crates-proxy/yaak-proxy-lib/src/flows.rs b/crates-proxy/yaak-proxy-lib/src/flows.rs index 27876ee6c..a01c4c97d 100644 --- a/crates-proxy/yaak-proxy-lib/src/flows.rs +++ b/crates-proxy/yaak-proxy-lib/src/flows.rs @@ -324,6 +324,27 @@ pub fn clear(db: &ProxyQueryManager) -> Result { .map_err(|e| format!("Failed to clear flows: {e}")) } +/// Deletes every flow and message from `proxy.db` in one transaction, with what was left of a +/// clear or sweep, for a launch that starts empty. Saved searches and gRPC schemas stay. Only +/// before anything else writes: unlike [`clear`], flows being written are not spared. Their +/// bodies are in `blobs.sqlite`, which goes whole ([`crate::db::remove_blobs`]). +pub fn delete_all(db: &ProxyQueryManager) -> Result<(), String> { + db.with_tx(|c| { + let conn = c.conn().resolve(); + // Unconditional, so SQLite drops each table's pages whole rather than row by row. + for table in [ + "flow_messages", + "flows", + "flow_clear_mark", + "flow_sweep_pending", + ] { + conn.execute(&format!("DELETE FROM {table}"), [])?; + } + Ok::<_, yaak_database::Error>(()) + }) + .map_err(|e| format!("Failed to delete captured traffic: {e}")) +} + /// Flows deleted per transaction while deleting cleared ones, so a flow being written never /// waits long behind it. pub const DELETE_BATCH: usize = 500; diff --git a/crates-proxy/yaak-proxy-lib/src/lib.rs b/crates-proxy/yaak-proxy-lib/src/lib.rs index fc935c80d..f03a6b84c 100644 --- a/crates-proxy/yaak-proxy-lib/src/lib.rs +++ b/crates-proxy/yaak-proxy-lib/src/lib.rs @@ -33,7 +33,7 @@ use crate::macos::{SystemProxyBackend, SystemProxyStatus}; use crate::messages::FlowMessage; use crate::models::{Flow, FlowBody, ModelPayload, ProxyHeader, StreamKind, find_header}; use crate::save::{ClientDb, SaveFlowRequest, SaveFlowResponse, WorkspaceSummary}; -use crate::settings::{DecryptList, HttpVersion, ProxySettings}; +use crate::settings::{DecryptList, HttpVersion, ProxySettings, TrafficRetention}; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64; use chrono::{DateTime, Utc}; @@ -106,6 +106,15 @@ pub struct ProxyCtx { resets: Arc, /// The thread keeping captured traffic under the keep-traffic cap, while there is one. sweeper: Arc<(Mutex, Condvar)>, + /// Captured traffic is to be emptied as the databases are first unlocked, this launch + /// being set to clear it. Taken once it is. + clear_at_launch: Arc, + /// The system proxy watch is running, so it is started once. + watching_system_proxy: Arc, + /// System proxy changes decided but not yet made, from when `system_proxy_on` is set until + /// the change is done. Until then the system proxy is not yet as meant, and following it + /// would undo the change before it is made. + system_proxy_pending: Arc, pub events: RpcEventEmitter, /// gRPC schemas as decoded, and the protoc that compiles attached protos. pub grpc: GrpcState, @@ -150,10 +159,18 @@ impl ProxyCtx { lock_reason: Arc::new(Mutex::new(None)), resets: Arc::new(AtomicU64::new(0)), sweeper: Default::default(), + clear_at_launch: Arc::new(AtomicBool::new(false)), + watching_system_proxy: Arc::new(AtomicBool::new(false)), + system_proxy_pending: Arc::new(AtomicU64::new(0)), events, grpc: GrpcState::default(), live_search: Default::default(), }; + // Unreadable settings keep the traffic: nothing is deleted on a guess. Nor is traffic + // another process is capturing as this launches, which this one may take over later. + let clear = ctx.settings().is_ok_and(|s| s.traffic() == TrafficRetention::ClearOnLaunch) + && control::live_owner(data_dir).is_none_or(|o| o.pid == std::process::id()); + ctx.clear_at_launch.store(clear, Ordering::SeqCst); if db::needs_encrypting(data_dir) { let unlocking = ctx.clone(); std::thread::spawn(move || { @@ -251,12 +268,24 @@ impl ProxyCtx { } let db_key = db::database_key(&key); + // Before converting, so bodies about to be deleted aren't encrypted first. + let clearing = self.take_clear_at_launch(); + if clearing && let Err(e) = db::remove_blobs(&self.data_dir) { + warn!("{e}"); + } + let converting = converting && db::needs_encrypting(&self.data_dir); if converting { let converted = self.encrypt_existing(&db_key); self.set_encrypting(None); converted?; } let stores = Stores::open(&self.data_dir, &db_key)?; + if clearing { + match flows::delete_all(&stores.db) { + Ok(()) => log::info!("Cleared captured traffic at launch"), + Err(e) => warn!("{e}"), + } + } // SQLCipher's exit handler is registered by now, so the gate's runs before it. exit_gate::install(); *self.stores.write().unwrap_or_else(PoisonError::into_inner) = Some(stores); @@ -273,6 +302,23 @@ impl ProxyCtx { Ok(key) } + /// Whether this unlock empties the captured traffic: the first to get this far since a + /// launch set to clear it. Not while another process owns the engine, as the CLI daemon + /// may: the traffic is its, still being written, and this app takes it over as it is. Asked + /// at launch too, as the first unlock may come later, once the keychain allows it. + fn take_clear_at_launch(&self) -> bool { + if !self.clear_at_launch.swap(false, Ordering::SeqCst) { + return false; + } + match control::live_owner(&self.data_dir) { + Some(owner) if owner.pid != std::process::id() => { + log::info!("Keeping captured traffic: {}", control::already_owned(&owner)); + false + } + _ => true, + } + } + /// Converts the plain databases, telling the app how far it has got a few times a second. fn encrypt_existing(&self, key: &yaak_database::encryption::DatabaseKey) -> Result<(), String> { let started = std::time::Instant::now(); @@ -440,11 +486,13 @@ impl ProxyCtx { *last_port = Some(bound); } let route = system_proxy.unwrap_or(settings.system_proxy_at_launch); + let pending = route.then(|| self.pending_system_proxy_change()); self.system_proxy_on.store(route, Ordering::SeqCst); - if route { + if let Some(pending) = pending { // The password dialog can sit open for a while; the engine is up regardless. let ctx = self.detached(); std::thread::spawn(move || { + let _pending = pending; if let Err(e) = ctx.change_system_proxy(true, bound, true) { warn!("Failed to turn on the system proxy: {e}"); ctx.system_proxy_on.store(false, Ordering::SeqCst); @@ -486,11 +534,13 @@ impl ProxyCtx { /// Stops the engine and turns off a system proxy that points at it. pub fn stop(&self) -> Result<(), String> { + let pending = self.pending_system_proxy_change(); self.system_proxy_on.store(false, Ordering::SeqCst); let port = self.take_handle()?; if let Some(port) = port { let ctx = self.detached(); std::thread::spawn(move || { + let _pending = pending; if let Err(e) = ctx.change_system_proxy(false, port, true) { warn!("Failed to turn off the system proxy: {e}"); } @@ -565,6 +615,8 @@ impl ProxyCtx { warn!("Failed to reset a stale system proxy: {e}"); } }); + // The owner turns the system proxy on and off, so it follows changes made underneath. + self.watch_system_proxy(); Ok(()) } @@ -692,6 +744,7 @@ impl ProxyCtx { /// password dialog. Not saved: the `systemProxyAtLaunch` setting decides whether it turns /// on as the engine starts. Blocks. pub fn set_system_proxy(&self, enabled: bool) -> Result { + let _pending = self.pending_system_proxy_change(); let Some(port) = self.port() else { if enabled { return Err("The proxy is not running, so there is nothing to route to".into()); @@ -799,6 +852,18 @@ impl ProxyCtx { Ok(bytes) } + /// Whether captured traffic is cleared at the next launch or kept, saved in `settings.json`. + /// Kept, the keep-traffic cap applies from now on; cleared, it is remembered but not + /// applied, and nothing already captured goes until the next launch. + pub fn set_traffic(&self, traffic: TrafficRetention) -> Result { + let mut settings = self.settings()?; + settings.traffic = Some(traffic); + settings.save(&self.settings_path())?; + self.sweep_soon(); + self.emit_state(); + Ok(traffic) + } + /// Bytes the captured traffic takes, as the keep-traffic cap counts them: the databases' /// used pages, so it shrinks as flows are deleted though the files do not. Locked, the /// files' sizes instead, as nothing in them can be read. @@ -916,9 +981,9 @@ impl ProxyCtx { } } - /// The keep-traffic cap, as `settings.json` has it. + /// The keep-traffic cap, as `settings.json` has it, while traffic is kept between launches. fn keep_traffic(&self) -> Option { - self.settings().ok().and_then(|s| s.keep_traffic_bytes) + self.settings().ok().and_then(|s| s.keep_traffic_cap()) } /// Sweeps the oldest flows while the captured traffic is over the cap, then waits for @@ -1093,6 +1158,88 @@ impl ProxyCtx { Ok(()) } + /// Marks a system proxy change as decided and not yet made, until the guard is dropped. + fn pending_system_proxy_change(&self) -> PendingChange { + self.system_proxy_pending.fetch_add(1, Ordering::SeqCst); + PendingChange(self.system_proxy_pending.clone()) + } + + /// Follows the system proxy as macOS reports changes to it, for as long as the process + /// runs, so a change made underneath the app, by hand or by another app such as Proxyman, + /// shows at once. Started once; a failure leaves the app reading it when the window is + /// focused, as before. + pub fn watch_system_proxy(&self) { + if self.watching_system_proxy.swap(true, Ordering::SeqCst) { + return; + } + let (tx, rx) = std::sync::mpsc::channel::<()>(); + if let Err(e) = macos::watch(move || { + let _ = tx.send(()); + }) { + warn!("Not following the system proxy: {e}"); + return; + } + let ctx = self.detached(); + let spawned = + std::thread::Builder::new().name("system-proxy-follow".into()).spawn(move || { + while rx.recv().is_ok() { + // One change comes as several notices, a service at a time. + while rx.recv_timeout(SYSTEM_PROXY_SETTLE).is_ok() {} + while !ctx.follow_system_proxy() { + std::thread::sleep(SYSTEM_PROXY_SETTLE); + } + } + }); + if let Err(e) = spawned { + warn!("Not following the system proxy: {e}"); + } + } + + /// Takes the system proxy as it is now: meant to point at the running engine while every + /// service sends its traffic there, and not otherwise. With nothing left pointing at the + /// engine's port, the settings saved for putting back are dropped, so stopping or quitting + /// restores nothing over a change someone else made. Tells the app either way. Returns + /// false, doing nothing, while a change of the app's own is under way, to be tried again. + /// Blocks. + fn follow_system_proxy(&self) -> bool { + if self.system_proxy_pending.load(Ordering::SeqCst) > 0 { + return false; + } + // Held so no change starts meanwhile, nor a change of ours is mistaken for another's. + let _turn = match self.system_proxy_turn.try_lock() { + Ok(turn) => turn, + Err(std::sync::TryLockError::Poisoned(turn)) => turn.into_inner(), + Err(std::sync::TryLockError::WouldBlock) => return false, + }; + if self.system_proxy_pending.load(Ordering::SeqCst) > 0 { + return false; + } + let running = self.port(); + let Some(port) = running.or_else(|| self.settings().ok().map(|s| s.port)) else { + return true; + }; + match macos::routing(port) { + Ok(routing) => { + if running.is_some() { + let was = self.system_proxy_on.swap(routing.every, Ordering::SeqCst); + if was != routing.every { + let now = if routing.every { "on" } else { "off" }; + debug!("The system proxy is {now} at port {port} after a change"); + } + } + if !routing.any + && let Err(e) = + macos::forget(port, self.system_proxy_backend(), &self.snapshot_path()) + { + warn!("Failed to forget the replaced proxy settings: {e}"); + } + } + Err(e) => warn!("Failed to read the system proxy: {e}"), + } + self.emit_state(); + true + } + /// Yaak has run on this machine, so there is a database to save into. pub fn client_exists(&self) -> bool { ClientDb::exists(&self.client_data_dir) @@ -1273,6 +1420,19 @@ fn remove_captured_when_closed(data_dir: &Path) -> Result<(), String> { } } +/// A system proxy change decided and not yet made, counted while it is held. +struct PendingChange(Arc); + +impl Drop for PendingChange { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::SeqCst); + } +} + +/// How long the system proxy is left to settle after a change is reported before it is read, +/// and how long a read waits for a change of the app's own to finish. +const SYSTEM_PROXY_SETTLE: Duration = Duration::from_millis(500); + /// Tries at deleting the captured databases, 100 ms apart, before a reset gives up. const REMOVE_TRIES: u32 = 50; @@ -1535,7 +1695,10 @@ pub struct GetProxyStateResponse { /// Bytes of each body stored per flow. #[ts(type = "number")] pub capture_body_limit: usize, - /// The most captured traffic kept, in bytes, or null to keep everything. + /// Captured traffic is cleared at launch or kept between launches. + pub traffic: TrafficRetention, + /// The most captured traffic kept, in bytes, or null to keep everything. Applies only + /// while traffic is kept. #[ts(type = "number | null")] pub keep_traffic_bytes: Option, /// Why the engine last failed to start, until it starts. @@ -1635,6 +1798,12 @@ pub struct SetCaptureBodyLimitRequest { pub bytes: u64, } +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct SetTrafficRequest { + pub traffic: TrafficRetention, +} + #[derive(Deserialize, TS)] #[ts(export, export_to = "gen_rpc.ts")] pub struct SetKeepTrafficRequest { @@ -2075,6 +2244,7 @@ async fn get_proxy_state( system_proxy_at_launch: settings.system_proxy_at_launch, http_version: settings.http_version, capture_body_limit: settings.capture_body_limit, + traffic: settings.traffic(), keep_traffic_bytes: settings.keep_traffic_bytes, start_error: ctx.start_error(), encrypting: ctx.encrypting(), @@ -2116,6 +2286,10 @@ fn set_capture_body_limit( ctx.set_capture_body_limit(bytes).map_err(rpc_error) } +fn set_traffic(ctx: &ProxyCtx, req: SetTrafficRequest) -> Result { + ctx.set_traffic(req.traffic).map_err(rpc_error) +} + /// The cap as saved, in bytes, or null to keep everything. fn set_keep_traffic(ctx: &ProxyCtx, req: SetKeepTrafficRequest) -> Result, RpcError> { let bytes = ctx.set_keep_traffic(req.bytes).map_err(rpc_error)?; @@ -3335,6 +3509,7 @@ define_rpc! { set_system_proxy_at_launch(SetSystemProxyAtLaunchRequest) -> bool, set_http_version(SetHttpVersionRequest) -> HttpVersion, set_capture_body_limit(SetCaptureBodyLimitRequest) -> usize, + set_traffic(SetTrafficRequest) -> TrafficRetention, set_keep_traffic(SetKeepTrafficRequest) -> Option, } async_commands { @@ -4396,9 +4571,17 @@ mod tests { assert_eq!(removed.len(), 20 - kept, "the app hears of each"); assert_eq!(removed, ids[..removed.len()], "the oldest, in order"); - // Set, the cap starts the sweeper, which finds nothing more to do; taken away, it stops. + // Clearing at launch, a cap is saved but not applied. assert_eq!(ctx.set_keep_traffic(Some(cap)).unwrap(), Some(cap)); assert_eq!(ctx.settings().unwrap().keep_traffic_bytes, Some(cap)); + assert!(!sweeper_running(&ctx), "no sweeping while traffic is cleared at launch"); + // Kept, the cap starts the sweeper, which finds nothing more to do; taken away, it + // stops, as it does when traffic goes back to being cleared at launch. + ctx.set_traffic(TrafficRetention::Keep).unwrap(); + assert!(sweeper_running(&ctx)); + ctx.set_traffic(TrafficRetention::ClearOnLaunch).unwrap(); + wait_until("the sweeper stops", || !sweeper_running(&ctx)); + ctx.set_traffic(TrafficRetention::Keep).unwrap(); assert!(sweeper_running(&ctx)); assert_eq!(ctx.set_keep_traffic(None).unwrap(), None); wait_until("the sweeper stops", || !sweeper_running(&ctx)); @@ -4632,6 +4815,75 @@ mod tests { ctx.stop().unwrap(); } + /// Opens the proxy in `dir` as a launch does, with `keys`, after writing two flows with + /// bodies and a saved search into it from a launch before. + fn relaunch_with_traffic( + dir: &Path, + keys: &MasterKeySource, + settings: Option, + ) -> ProxyCtx { + { + let (events, _rx) = RpcEventEmitter::new(); + let ctx = ProxyCtx::new(dir, events, keys.clone()).unwrap(); + flow_with_response(&ctx, "fl_a", "text/plain", b"one"); + flow_with_response(&ctx, "fl_b", "text/plain", b"two"); + saved_queries::create(&ctx.db().unwrap(), "Errors", "@status:>=400").unwrap(); + assert_eq!(flows::count(&ctx.db().unwrap()).unwrap(), 2); + } + if let Some(settings) = settings { + settings.save(&dir.join("settings.json")).unwrap(); + } + let (events, _rx) = RpcEventEmitter::new(); + ProxyCtx::new(dir, events, keys.clone()).unwrap() + } + + /// By default a launch starts with no traffic, bodies included, and keeps saved searches. + #[test] + fn a_launch_clears_captured_traffic_by_default() { + let dir = tempfile::tempdir().unwrap(); + let keys = MasterKeySource::memory(); + let ctx = relaunch_with_traffic(dir.path(), &keys, None); + let db = ctx.db().unwrap(); + assert_eq!(flows::count(&db).unwrap(), 0); + assert_eq!(blobs_of(&ctx, "fl_a", 0), 0); + let saved = saved_queries::list(&db).unwrap(); + assert_eq!(saved.iter().map(|s| s.name.as_str()).collect::>(), ["Errors"]); + // New traffic is written as before. + flow_with_response(&ctx, "fl_c", "text/plain", b"three"); + assert_eq!(flows::count(&db).unwrap(), 1); + } + + /// Kept between launches, or with a cap set before the choice existed, traffic stays. + #[test] + fn a_launch_keeps_traffic_when_set_to() { + let keys = MasterKeySource::memory(); + let keep = ProxySettings { traffic: Some(TrafficRetention::Keep), ..Default::default() }; + // From before the choice: a cap and no `traffic` key. + let capped: ProxySettings = + serde_json::from_str(r#"{"keepTrafficBytes": 1073741824}"#).unwrap(); + assert_eq!(capped.traffic, None); + for settings in [keep, capped] { + let dir = tempfile::tempdir().unwrap(); + let ctx = relaunch_with_traffic(dir.path(), &keys, Some(settings)); + assert_eq!(flows::count(&ctx.db().unwrap()).unwrap(), 2); + assert_eq!(blobs_of(&ctx, "fl_a", 0), 1); + } + } + + /// Only a launch clears: unlocking again, after a reset, keeps what was captured since. + #[test] + fn only_the_first_unlock_clears() { + let dir = tempfile::tempdir().unwrap(); + let (events, _rx) = RpcEventEmitter::new(); + let ctx = ProxyCtx::new(dir.path(), events, MasterKeySource::memory()).unwrap(); + assert!(!ctx.take_clear_at_launch(), "taken by the launch"); + flow_with_response(&ctx, "fl_a", "text/plain", b"one"); + ctx.reset_captured_data().unwrap(); + flow_with_response(&ctx, "fl_b", "text/plain", b"two"); + assert!(ctx.unlock().is_ok()); + assert_eq!(flows::count(&ctx.db().unwrap()).unwrap(), 1); + } + /// The capture limit is saved and shows in the proxy's state, as the keep-traffic cap does. #[tokio::test(flavor = "multi_thread")] async fn the_state_has_the_capture_limit_and_the_traffic_cap() { @@ -4644,6 +4896,14 @@ mod tests { assert_eq!(set_keep_traffic(&ctx, req).unwrap(), Some(1 << 30)); let state = get_proxy_state(ctx.clone(), GetProxyStateRequest {}).await.unwrap(); assert_eq!((state.capture_body_limit, state.keep_traffic_bytes), (4096, Some(1 << 30))); + assert_eq!( + state.traffic, + TrafficRetention::ClearOnLaunch, + "a cap set now keeps the choice" + ); + set_traffic(&ctx, SetTrafficRequest { traffic: TrafficRetention::Keep }).unwrap(); + let state = get_proxy_state(ctx.clone(), GetProxyStateRequest {}).await.unwrap(); + assert_eq!(state.traffic, TrafficRetention::Keep); let size = get_storage_size(ctx.clone(), GetStorageSizeRequest {}).await.unwrap(); assert!(size.bytes > 0); ctx.set_keep_traffic(None).unwrap(); diff --git a/crates-proxy/yaak-proxy-lib/src/macos.rs b/crates-proxy/yaak-proxy-lib/src/macos.rs index 7a319306b..6224a5329 100644 --- a/crates-proxy/yaak-proxy-lib/src/macos.rs +++ b/crates-proxy/yaak-proxy-lib/src/macos.rs @@ -152,6 +152,61 @@ fn set_scripted( } } +/// Where the system proxy points, as far as one port is concerned. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Routing { + /// Every enabled service sends HTTP and HTTPS to the port: the override is on. + pub every: bool, + /// Some service, enabled or not, still sends one of them to the port: something the app + /// set may be left to put back. + pub any: bool, +} + +/// [`Routing`] at `port` through `ns`. Disabled services are read only when no enabled one +/// routes everything, which is the common case worth the extra `networksetup` calls. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn routing_with(ns: &impl Networksetup, port: u16) -> Result { + let enabled = read_services(ns, false)?; + let every = !enabled.is_empty() + && enabled.iter().all(|s| s.http.points_at(port) && s.https.points_at(port)); + if every { + return Ok(Routing { every, any: true }); + } + let any = read_services(ns, true)?.iter().any(|s| s.points_at(port)); + Ok(Routing { every, any }) +} + +/// Drops the settings the password dialog or `sudo` saved for putting back once routing at +/// `port` ends, when nothing points at it any more: someone else changed the system proxy +/// since, and what is there now is theirs. Saved settings for a service no longer present stay +/// parked, as a restore would leave them. Never asks for rights: it only rewrites the file. +/// Returns whether anything was dropped. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn forget_scripted( + ns: &impl Networksetup, + port: u16, + snapshot_path: &Path, +) -> Result { + let Some(snapshot) = load_snapshot(snapshot_path)? else { + return Ok(false); + }; + // Settings parked at port 0, a restore halfway through, or routing at another port are + // left to what handles them. + if snapshot.port == 0 || snapshot.port != port || !snapshot.restoring.is_empty() { + return Ok(false); + } + let services = read_services(ns, true)?; + let ports = snapshot.ports(); + if services.iter().any(|s| ports.iter().any(|p| s.points_at(*p))) { + return Ok(false); + } + match snapshot.leftover(&services) { + Some(leftover) => save_snapshot(snapshot_path, &leftover)?, + None => remove_snapshot(snapshot_path)?, + } + Ok(true) +} + /// The calls as one `sh` script that stops at the first failure. #[cfg_attr(not(target_os = "macos"), allow(dead_code))] fn script(commands: &[Vec]) -> String { @@ -269,6 +324,104 @@ pub fn set( } } +/// Where the system proxy points now, judged against `port`. Blocks. +#[cfg(target_os = "macos")] +pub fn routing(port: u16) -> Result { + routing_with(&yaak_proxy_helper::networksetup::SystemNetworksetup, port) +} + +/// Drops what was saved for putting back once routing at `port` ends, when nothing points at +/// it any more, so stopping or quitting restores nothing over someone else's change. Never +/// asks for rights: the helper is root already, and the other paths only rewrite their file. +/// Blocks. +#[cfg(target_os = "macos")] +pub fn forget(port: u16, backend: SystemProxyBackend, snapshot_path: &Path) -> Result<(), String> { + match backend { + // With nothing pointing at `port`, turning off there changes nothing and drops what + // the helper saved. + SystemProxyBackend::Helper => crate::helper::set(false, port), + SystemProxyBackend::AdminPrompt | SystemProxyBackend::Sudo => { + let ns = yaak_proxy_helper::networksetup::SystemNetworksetup; + if forget_scripted(&ns, port, snapshot_path)? { + log::info!( + "The system proxy no longer points at port {port}; forgot what it replaced" + ); + } + Ok(()) + } + } +} + +/// Calls `changed`, on a thread of its own, each time macOS reports a change to a proxy +/// setting: by this app, another one such as Proxyman, or the user in System Settings. Several +/// may come for one change. Runs for as long as the process does. +#[cfg(target_os = "macos")] +pub fn watch(changed: impl Fn() + Send + 'static) -> Result<(), String> { + use core_foundation::array::CFArray; + use core_foundation::runloop::{CFRunLoop, kCFRunLoopCommonModes}; + use core_foundation::string::CFString; + use system_configuration::dynamic_store::{ + SCDynamicStore, SCDynamicStoreBuilder, SCDynamicStoreCallBackContext, + }; + + type Changed = Box; + fn callout(_store: SCDynamicStore, _keys: CFArray, changed: &mut Changed) { + changed(); + } + + let changed: Changed = Box::new(changed); + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + std::thread::Builder::new() + .name("system-proxy-watch".into()) + .spawn(move || { + let context = SCDynamicStoreCallBackContext { callout, info: changed }; + let Some(store) = + SCDynamicStoreBuilder::new("yaak-proxy").callback_context(context).build() + else { + let _ = ready_tx.send(Err("Failed to open the dynamic store".to_string())); + return; + }; + // The proxies in effect, and each service's own, as `networksetup` changes them. + let keys = CFArray::from_CFTypes(&[CFString::from("State:/Network/Global/Proxies")]); + let patterns = + CFArray::from_CFTypes(&[CFString::from("Setup:/Network/Service/[^/]+/Proxies")]); + if !store.set_notification_keys(&keys, &patterns) { + let _ = ready_tx.send(Err("Failed to watch the proxy settings".to_string())); + return; + } + let Some(source) = store.create_run_loop_source() else { + let _ = ready_tx.send(Err("Failed to watch the proxy settings".to_string())); + return; + }; + // SAFETY: a constant CoreFoundation exports. + CFRunLoop::get_current().add_source(&source, unsafe { kCFRunLoopCommonModes }); + let _ = ready_tx.send(Ok(())); + CFRunLoop::run_current(); + }) + .map_err(|e| format!("Failed to start watching the system proxy: {e}"))?; + ready_rx.recv().map_err(|_| "The system proxy watch stopped".to_string())? +} + +#[cfg(not(target_os = "macos"))] +pub fn routing(_port: u16) -> Result { + Ok(Routing { every: false, any: false }) +} + +#[cfg(not(target_os = "macos"))] +pub fn forget( + _port: u16, + _backend: SystemProxyBackend, + _snapshot_path: &Path, +) -> Result<(), String> { + Ok(()) +} + +/// No system proxy to watch here. +#[cfg(not(target_os = "macos"))] +pub fn watch(_changed: impl Fn() + Send + 'static) -> Result<(), String> { + Ok(()) +} + #[cfg(not(target_os = "macos"))] pub fn status( port: u16, @@ -478,6 +631,75 @@ mod tests { assert_eq!(ethernet.http.server, HOST); } + #[test] + fn routing_follows_what_the_services_point_at() { + let ns = fake(); + let prompts = std::cell::Cell::new(0); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(SNAPSHOT_FILE); + assert_eq!(routing_with(&ns, 9090).unwrap(), Routing { every: false, any: false }); + + set_scripted(&ns, runner(&ns, &prompts), true, 9090, &path).unwrap(); + assert_eq!(routing_with(&ns, 9090).unwrap(), Routing { every: true, any: true }); + + // Another app moves one proxy of one service to its own port. + let wifi = ns.proxies()[0].clone(); + ns.set_proxy("Wi-Fi", setting(true, HOST, 9091), wifi.https); + assert_eq!(routing_with(&ns, 9090).unwrap(), Routing { every: false, any: true }); + + // A disabled service still pointing here counts as something to put back. + ns.set_proxy("Wi-Fi", setting(true, HOST, 9091), setting(true, HOST, 9091)); + ns.set_enabled("Ethernet", false); + assert_eq!(routing_with(&ns, 9090).unwrap(), Routing { every: false, any: true }); + } + + #[test] + fn a_proxy_changed_underneath_forgets_what_it_replaced() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(SNAPSHOT_FILE); + let ns = fake(); + let prompts = std::cell::Cell::new(0); + set_scripted(&ns, runner(&ns, &prompts), true, 9090, &path).unwrap(); + + // Still pointing here: kept. + assert!(!forget_scripted(&ns, 9090, &path).unwrap()); + assert!(path.exists()); + + // Another app, Proxyman say, points everything at its own port. + let theirs = (setting(true, HOST, 9091), setting(true, HOST, 9091)); + ns.set_proxy("Wi-Fi", theirs.0.clone(), theirs.1.clone()); + ns.set_proxy("Ethernet", theirs.0.clone(), theirs.1.clone()); + // Checked against another port, nothing changes. + assert!(!forget_scripted(&ns, 8080, &path).unwrap()); + assert!(forget_scripted(&ns, 9090, &path).unwrap()); + assert!(!path.exists()); + + // Quitting now restores nothing over theirs, and asks for nothing. + let before = prompts.get(); + set_scripted(&ns, runner(&ns, &prompts), false, 9090, &path).unwrap(); + assert_eq!(prompts.get(), before); + assert!(ns.proxies().iter().all(|s| s.http == theirs.0 && s.https == theirs.1)); + } + + #[test] + fn forgetting_keeps_settings_parked_for_a_missing_service() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(SNAPSHOT_FILE); + let ns = fake(); + let prompts = std::cell::Cell::new(0); + set_scripted(&ns, runner(&ns, &prompts), true, 9090, &path).unwrap(); + ns.remove("Ethernet"); + ns.set_proxy("Wi-Fi", ProxySetting::default(), ProxySetting::default()); + + assert!(forget_scripted(&ns, 9090, &path).unwrap()); + let kept = load_snapshot(&path).unwrap().unwrap(); + assert_eq!(kept.port, 0, "parked, routing nothing"); + assert_eq!(kept.services.len(), 1); + assert_eq!(kept.services[0].name, "Ethernet"); + // Parked settings aren't forgotten again. + assert!(!forget_scripted(&ns, 9090, &path).unwrap()); + } + #[test] fn quotes_service_names() { let commands = vec![vec![ diff --git a/crates-proxy/yaak-proxy-lib/src/settings.rs b/crates-proxy/yaak-proxy-lib/src/settings.rs index 969d04523..d32f55ac7 100644 --- a/crates-proxy/yaak-proxy-lib/src/settings.rs +++ b/crates-proxy/yaak-proxy-lib/src/settings.rs @@ -15,8 +15,13 @@ pub struct ProxySettings { pub system_proxy_at_launch: bool, /// Bytes of each request and response body stored per flow. pub capture_body_limit: usize, + /// Whether captured traffic is cleared as the app or daemon launches or kept between + /// launches. Missing, as in a file from before the choice, it is read from + /// `keepTrafficBytes`: a cap set there means the traffic was meant to be kept. + #[serde(default)] + pub traffic: Option, /// The most captured traffic kept, in bytes of the databases' used pages; the oldest flows - /// are deleted past it. None keeps everything. + /// are deleted past it. None keeps everything. Only applies while traffic is kept. pub keep_traffic_bytes: Option, /// The HTTP versions offered to clients in intercepted HTTPS. pub http_version: HttpVersion, @@ -34,6 +39,7 @@ impl Default for ProxySettings { port: 9090, system_proxy_at_launch: true, capture_body_limit: DEFAULT_CAPTURE_BODY_LIMIT, + traffic: Some(TrafficRetention::ClearOnLaunch), keep_traffic_bytes: None, http_version: HttpVersion::Auto, decrypt: Vec::new(), @@ -42,6 +48,18 @@ impl Default for ProxySettings { } } +/// What happens to captured traffic between launches. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_rpc.ts")] +pub enum TrafficRetention { + /// Emptied as the app or daemon launches, before anything reads it. + #[default] + ClearOnLaunch, + /// Kept, under the keep-traffic cap when there is one. + Keep, +} + /// One entry of the decrypt list, as the app shows it. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] @@ -76,6 +94,25 @@ pub enum HttpVersion { } impl ProxySettings { + /// Whether traffic is cleared at launch or kept. A file from before the choice keeps it + /// when it has a keep-traffic cap, so an update doesn't delete a capture someone meant to + /// keep. + pub fn traffic(&self) -> TrafficRetention { + self.traffic.unwrap_or(if self.keep_traffic_bytes.is_some() { + TrafficRetention::Keep + } else { + TrafficRetention::ClearOnLaunch + }) + } + + /// The keep-traffic cap, while traffic is kept. Cleared at launch, nothing is swept. + pub fn keep_traffic_cap(&self) -> Option { + match self.traffic() { + TrafficRetention::Keep => self.keep_traffic_bytes, + TrafficRetention::ClearOnLaunch => None, + } + } + /// The user's entries, then the built-in exclusions they kept. pub fn decrypt_list(&self) -> DecryptList { let user = @@ -157,8 +194,12 @@ impl ProxySettings { /// The settings at `path`, or the defaults when there is no file. pub fn load(path: &Path) -> Result { match std::fs::read_to_string(path) { - Ok(contents) => serde_json::from_str(&contents) - .map_err(|e| format!("Invalid settings in {}: {e}", path.display())), + Ok(contents) => { + let mut settings: Self = serde_json::from_str(&contents) + .map_err(|e| format!("Invalid settings in {}: {e}", path.display()))?; + settings.traffic = Some(settings.traffic()); + Ok(settings) + } Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), Err(e) => Err(format!("Failed to read {}: {e}", path.display())), } @@ -182,7 +223,35 @@ mod tests { assert_eq!(settings.port, 9090); assert!(settings.system_proxy_at_launch); assert_eq!(settings.capture_body_limit, 10 * 1024 * 1024); - assert_eq!(settings.keep_traffic_bytes, None, "everything is kept"); + assert_eq!(settings.keep_traffic_bytes, None, "no cap"); + assert_eq!(settings.traffic(), TrafficRetention::ClearOnLaunch); + } + + #[test] + fn a_file_from_before_the_choice_keeps_traffic_when_it_had_a_cap() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + std::fs::write(&path, r#"{"port": 9090, "keepTrafficBytes": 1073741824}"#).unwrap(); + let settings = ProxySettings::load(&path).unwrap(); + assert_eq!(settings.traffic(), TrafficRetention::Keep); + assert_eq!(settings.keep_traffic_cap(), Some(1 << 30)); + + std::fs::write(&path, r#"{"port": 9090, "keepTrafficBytes": null}"#).unwrap(); + let settings = ProxySettings::load(&path).unwrap(); + assert_eq!(settings.traffic(), TrafficRetention::ClearOnLaunch); + + // Chosen once, the choice holds whatever the cap. + std::fs::write(&path, r#"{"traffic": "clearOnLaunch", "keepTrafficBytes": 1024}"#).unwrap(); + let settings = ProxySettings::load(&path).unwrap(); + assert_eq!(settings.traffic(), TrafficRetention::ClearOnLaunch); + assert_eq!(settings.keep_traffic_cap(), None, "the cap applies only while keeping"); + assert_eq!(settings.keep_traffic_bytes, Some(1024), "but is remembered"); + + // Saved, the choice is written out, so setting a cap later doesn't change it. + settings.save(&path).unwrap(); + let json: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(json["traffic"], "clearOnLaunch"); } #[test] @@ -202,6 +271,7 @@ mod tests { port: 8080, system_proxy_at_launch: false, capture_body_limit: 1024, + traffic: Some(TrafficRetention::Keep), keep_traffic_bytes: Some(1 << 30), http_version: HttpVersion::Http1Only, decrypt: vec!["host:api.example.com".into()], @@ -218,6 +288,7 @@ mod tests { "port": 8080, "systemProxyAtLaunch": false, "captureBodyLimit": 1024, + "traffic": "keep", "keepTrafficBytes": 1073741824, "httpVersion": "http1Only", "decrypt": ["host:api.example.com"], diff --git a/crates-proxy/yaak-proxy-lib/tests/at_rest.rs b/crates-proxy/yaak-proxy-lib/tests/at_rest.rs index 65cb8f72a..68d18aace 100644 --- a/crates-proxy/yaak-proxy-lib/tests/at_rest.rs +++ b/crates-proxy/yaak-proxy-lib/tests/at_rest.rs @@ -16,7 +16,7 @@ use yaak_proxy_lib::client::ProxyConnection; use yaak_proxy_lib::db::{BLOBS_DB, DATA_KEY, PROXY_DB, database_key}; use yaak_proxy_lib::flows::{self, FlowFilter}; use yaak_proxy_lib::models::{Flow, FlowBody, ProxyHeader}; -use yaak_proxy_lib::settings::ProxySettings; +use yaak_proxy_lib::settings::{ProxySettings, TrafficRetention}; use yaak_proxy_lib::{MasterKeySource, ProxyCtx}; use yaak_rpc::RpcEventEmitter; @@ -25,8 +25,15 @@ const QUERY_SECRET: &str = "QUERYSECRET456"; const BODY_SECRET: &str = "BODYSECRET789"; const FLOWS: usize = 3_000; +/// The proxy in `dir`, launched with traffic kept between launches, so what one launch stored +/// the next reads back. fn open_ctx(dir: &Path, keys: MasterKeySource) -> ProxyCtx { - let settings = ProxySettings { port: 0, system_proxy_at_launch: false, ..Default::default() }; + let settings = ProxySettings { + port: 0, + system_proxy_at_launch: false, + traffic: Some(TrafficRetention::Keep), + ..Default::default() + }; settings.save(&dir.join("settings.json")).unwrap(); let (events, _rx) = RpcEventEmitter::new(); ProxyCtx::new(dir, events, keys).unwrap() @@ -436,6 +443,38 @@ fn waits_for_an_older_owner_before_converting() { } } +/// Launched while the CLI daemon captures, set to clear traffic at launch, the app keeps the +/// daemon's traffic: it is still being written, and is what taking over shows. +#[cfg(unix)] +#[test] +fn a_launch_under_another_owner_keeps_its_traffic() { + use yaak_proxy_lib::control::{ControlInfo, Owner, control_path}; + let dir = plain_capture(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let owner = ControlInfo { + pid: std::os::unix::process::parent_id(), + control_port: listener.local_addr().unwrap().port(), + proxy_port: None, + token: "tok".into(), + owner: Owner::Cli, + started_at: chrono::Utc::now(), + }; + let control = control_path(dir.path()); + std::fs::write(&control, serde_json::to_string(&owner).unwrap()).unwrap(); + let settings = ProxySettings { port: 0, system_proxy_at_launch: false, ..Default::default() }; + assert_eq!(settings.traffic(), TrafficRetention::ClearOnLaunch); + settings.save(&dir.path().join("settings.json")).unwrap(); + let (events, _rx) = RpcEventEmitter::new(); + let ctx = ProxyCtx::new(dir.path(), events, MasterKeySource::memory()).unwrap(); + + // Taken over once the daemon lets go: every flow it captured is there. + drop(listener); + std::fs::remove_file(&control).unwrap(); + ctx.start().unwrap(); + assert_eq!(flows::count(&ctx.db().unwrap()).unwrap(), FLOWS); + ctx.stop().unwrap(); +} + /// The data key's file gone from beside encrypted databases is refused too, rather than a new /// key being made that could open nothing and would replace the trusted CA. #[test] diff --git a/crates-proxy/yaak-proxy-lib/tests/flow.rs b/crates-proxy/yaak-proxy-lib/tests/flow.rs index b08109fc1..f00815e71 100644 --- a/crates-proxy/yaak-proxy-lib/tests/flow.rs +++ b/crates-proxy/yaak-proxy-lib/tests/flow.rs @@ -337,6 +337,7 @@ async fn body_rpcs() { "systemProxyAtLaunch": false, "httpVersion": "auto", "captureBodyLimit": CAPTURE_LIMIT, + "traffic": "clearOnLaunch", "keepTrafficBytes": null, "startError": null, "encrypting": null,