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,