From f7aea62c072a44dad1799113fa47c4c8d91c5f56 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Fri, 30 Oct 2026 00:41:00 -0700 Subject: [PATCH] Privileged helper for system proxy (#796) Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 31 +- Cargo.toml | 2 + apps/yaak-proxy/assets/login-items.png | Bin 0 -> 73821 bytes apps/yaak-proxy/components/HelperDialog.tsx | 96 ++ apps/yaak-proxy/components/ProxyLayout.tsx | 2 + apps/yaak-proxy/components/ProxyStatus.tsx | 95 +- apps/yaak-proxy/lib/atoms.ts | 6 + apps/yaak-proxy/lib/systemProxy.test.ts | 118 ++- apps/yaak-proxy/lib/systemProxy.ts | 90 +- apps/yaak-proxy/lib/tauri.ts | 11 + apps/yaak-proxy/lib/unsubscribe.test.ts | 34 + apps/yaak-proxy/lib/unsubscribe.ts | 25 + crates-proxy/yaak-proxy-lib/Cargo.toml | 9 + .../yaak-proxy-lib/bindings/gen_rpc.ts | 19 +- crates-proxy/yaak-proxy-lib/src/helper.rs | 222 ++++ crates-proxy/yaak-proxy-lib/src/lib.rs | 105 +- crates-proxy/yaak-proxy-lib/src/macos.rs | 477 ++++++--- crates-tauri/yaak-app-proxy/.gitignore | 2 + .../macos/app.yaak.proxy.helper.plist | 27 + .../yaak-app-proxy/macos/entitlements.plist | 8 + crates-tauri/yaak-app-proxy/src/main.rs | 5 + .../yaak-app-proxy/tauri.bundle.conf.json | 16 + crates-tauri/yaak-proxy-helper/Cargo.toml | 34 + crates-tauri/yaak-proxy-helper/src/auth.rs | 121 +++ crates-tauri/yaak-proxy-helper/src/daemon.rs | 783 +++++++++++++++ crates-tauri/yaak-proxy-helper/src/lib.rs | 68 ++ crates-tauri/yaak-proxy-helper/src/main.rs | 10 + .../yaak-proxy-helper/src/networksetup.rs | 944 ++++++++++++++++++ crates-tauri/yaak-proxy-helper/src/process.rs | 77 ++ .../yaak-proxy-helper/src/protocol.rs | 151 +++ package.json | 3 + scripts/bundle-proxy.mjs | 72 ++ scripts/vendor-proxy-helper.cjs | 49 + 33 files changed, 3536 insertions(+), 176 deletions(-) create mode 100644 apps/yaak-proxy/assets/login-items.png create mode 100644 apps/yaak-proxy/components/HelperDialog.tsx create mode 100644 apps/yaak-proxy/lib/unsubscribe.test.ts create mode 100644 apps/yaak-proxy/lib/unsubscribe.ts create mode 100644 crates-proxy/yaak-proxy-lib/src/helper.rs create mode 100644 crates-tauri/yaak-app-proxy/macos/app.yaak.proxy.helper.plist create mode 100644 crates-tauri/yaak-app-proxy/macos/entitlements.plist create mode 100644 crates-tauri/yaak-app-proxy/tauri.bundle.conf.json create mode 100644 crates-tauri/yaak-proxy-helper/Cargo.toml create mode 100644 crates-tauri/yaak-proxy-helper/src/auth.rs create mode 100644 crates-tauri/yaak-proxy-helper/src/daemon.rs create mode 100644 crates-tauri/yaak-proxy-helper/src/lib.rs create mode 100644 crates-tauri/yaak-proxy-helper/src/main.rs create mode 100644 crates-tauri/yaak-proxy-helper/src/networksetup.rs create mode 100644 crates-tauri/yaak-proxy-helper/src/process.rs create mode 100644 crates-tauri/yaak-proxy-helper/src/protocol.rs create mode 100644 scripts/bundle-proxy.mjs create mode 100644 scripts/vendor-proxy-helper.cjs diff --git a/Cargo.lock b/Cargo.lock index 577ac7215..cb39b8a24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4977,15 +4977,27 @@ dependencies = [ [[package]] name = "objc2-security" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1f8e0ef3ab66b08c42644dcb34dba6ec0a574bbd8adbb8bdbdc7a2779731a44" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" dependencies = [ "bitflags 2.11.0", "objc2 0.6.4", "objc2-core-foundation", ] +[[package]] +name = "objc2-service-management" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b213642d6959cc6023ceb1217aa595eaaf09b8094ce95127c103cab611fe65e8" +dependencies = [ + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-security", +] + [[package]] name = "objc2-ui-kit" version = "0.3.1" @@ -11753,6 +11765,19 @@ dependencies = [ "tokio-tungstenite", ] +[[package]] +name = "yaak-proxy-helper" +version = "0.0.0" +dependencies = [ + "core-foundation 0.10.1", + "libc", + "security-framework 3.5.1", + "serde", + "serde_json", + "tempfile", + "ts-rs", +] + [[package]] name = "yaak-proxy-lib" version = "0.0.0" @@ -11770,6 +11795,7 @@ dependencies = [ "objc2 0.6.4", "objc2-app-kit", "objc2-foundation 0.3.2", + "objc2-service-management", "plist", "r2d2", "r2d2_sqlite", @@ -11789,6 +11815,7 @@ dependencies = [ "yaak-lifecycle", "yaak-models", "yaak-proxy", + "yaak-proxy-helper", "yaak-rpc", "yaak-templates", ] diff --git a/Cargo.toml b/Cargo.toml index fbc8e874d..40557a8ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ members = [ "crates-tauri/yaak-fonts", "crates-tauri/yaak-license", "crates-tauri/yaak-mac-window", + "crates-tauri/yaak-proxy-helper", "crates-tauri/yaak-system-appearance", "crates-tauri/yaak-tauri-utils", "crates-tauri/yaak-window", @@ -110,6 +111,7 @@ yaak-proxy-lib = { path = "crates-proxy/yaak-proxy-lib" } yaak-fonts = { path = "crates-tauri/yaak-fonts" } yaak-license = { path = "crates-tauri/yaak-license" } yaak-mac-window = { path = "crates-tauri/yaak-mac-window" } +yaak-proxy-helper = { path = "crates-tauri/yaak-proxy-helper" } yaak-system-appearance = { path = "crates-tauri/yaak-system-appearance" } yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" } yaak-window = { path = "crates-tauri/yaak-window" } diff --git a/apps/yaak-proxy/assets/login-items.png b/apps/yaak-proxy/assets/login-items.png new file mode 100644 index 0000000000000000000000000000000000000000..fad5628f6600aa70e26db1a9f5c8bc132377871d GIT binary patch literal 73821 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 z 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 z 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 mhb(d&)SYp7JpLSP^@~iP?SZU?XBLDVz;JO literal 0 HcmV?d00001 diff --git a/apps/yaak-proxy/components/HelperDialog.tsx b/apps/yaak-proxy/components/HelperDialog.tsx new file mode 100644 index 000000000..a440ae97c --- /dev/null +++ b/apps/yaak-proxy/components/HelperDialog.tsx @@ -0,0 +1,96 @@ +import { useQueryClient } from "@tanstack/react-query"; +import type { SystemProxyStatus } from "@yaakapp-internal/proxy-lib"; +import { Banner, Dialog, DialogFooter, LoadingIcon } from "@yaakapp-internal/ui"; +import { useAtom, useSetAtom } from "jotai"; +import { useEffect } from "react"; +import loginItems from "../assets/login-items.png"; +import { useRpcMutation } from "../hooks/useRpcMutation"; +import { useRpcQuery } from "../hooks/useRpcQuery"; +import { helperDialogOpenAtom, helperRegisteredAtAtom } from "../lib/atoms"; + +/** Yaak Proxy's row in `login-items.png`, as percentages of the image, for the highlight */ +const YAAK_PROXY_ROW = { left: 1, top: 34, width: 98, height: 32 }; + +/** + * Walks through allowing the helper: registering it opens System Settings, where the user turns + * it on. Stays open while macOS awaits that approval, and closes itself once it is given. + */ +export function HelperDialog() { + const [open, setOpen] = useAtom(helperDialogOpenAtom); + const queryClient = useQueryClient(); + const setRegisteredAt = useSetAtom(helperRegisteredAtAtom); + // Polled by the header while approval is pending, so this sees it as soon as it is given. + const status = useRpcQuery("system_proxy_status", {}); + const register = useRpcMutation("helper_register", { + onSuccess: (status) => { + setRegisteredAt(Date.now()); + queryClient.setQueryData(["system_proxy_status", {}], status); + }, + }); + const helper = status.data?.helper; + const waiting = helper === "requires_approval"; + const { reset } = register; + + useEffect(() => { + if (open && helper === "enabled") { + setOpen(false); + reset(); + } + }, [open, helper, setOpen, reset]); + + const close = () => { + setOpen(false); + reset(); + }; + + return ( +

+
+
+ System Settings, with Yaak Proxy's toggle in the list of apps allowed in the background +
+
+ {register.error != null && {register.error.message}} +
+ + + Waiting for approval + + ) + } + actions={[ + { label: waiting ? "Close" : "Cancel", onClick: close }, + { + label: "Open System Settings", + color: "primary", + isLoading: register.isPending, + onClick: () => register.mutate({}), + }, + ]} + /> +
+ ); +} diff --git a/apps/yaak-proxy/components/ProxyLayout.tsx b/apps/yaak-proxy/components/ProxyLayout.tsx index 62efaa7e3..46e3017e9 100644 --- a/apps/yaak-proxy/components/ProxyLayout.tsx +++ b/apps/yaak-proxy/components/ProxyLayout.tsx @@ -18,6 +18,7 @@ import { FirstRun } from "./FirstRun"; import { FlowDetail } from "./FlowDetail"; import { FlowList } from "./FlowList"; import { ProxyStatus } from "./ProxyStatus"; +import { HelperDialog } from "./HelperDialog"; import { SaveDialog } from "./SaveDialog"; import { selectedSidebarLabelAtom, Sidebar } from "./Sidebar"; import { TakeOverBanner } from "./TakeOverBanner"; @@ -139,6 +140,7 @@ export function ProxyLayout() { + ); diff --git a/apps/yaak-proxy/components/ProxyStatus.tsx b/apps/yaak-proxy/components/ProxyStatus.tsx index 4c92bd8d1..48a9779f5 100644 --- a/apps/yaak-proxy/components/ProxyStatus.tsx +++ b/apps/yaak-proxy/components/ProxyStatus.tsx @@ -9,15 +9,22 @@ import { Icon, } from "@yaakapp-internal/ui"; import classNames from "classnames"; -import { useAtomValue, useStore } from "jotai"; +import { useAtomValue, useSetAtom, useStore } from "jotai"; import type { HTMLAttributes, ReactNode, Ref } from "react"; -import { useRef } from "react"; +import { useEffect, useRef } from "react"; import { useRpcEvent } from "../hooks/useRpcEvent"; import { useRpcMutation } from "../hooks/useRpcMutation"; import { useRpcQuery } from "../hooks/useRpcQuery"; +import { helperDialogOpenAtom, helperRegisteredAtAtom } from "../lib/atoms"; import { environmentExports } from "../lib/env"; import { proxyStateAtom, refreshProxyState } from "../lib/store"; -import { recordingLabel, systemProxySwitch } from "../lib/systemProxy"; +import { + helperPollInterval, + helperRow, + recordingLabel, + systemProxySwitch, +} from "../lib/systemProxy"; +import { onWindowFocus } from "../lib/tauri"; import { useShowToast } from "../lib/toast"; /** @@ -34,6 +41,7 @@ export function ProxyStatus() { const setAtLaunch = useSetSystemProxyAtLaunch(); const setHttpVersion = useSetHttpVersion(); const copyEnvironment = useCopyEnvironment(); + const setHelperDialogOpen = useSetAtom(helperDialogOpenAtom); const { caStatus, trust, systemProxy, setSystemProxy } = setup; const systemSwitch = useSystemProxySwitch(setup); @@ -131,6 +139,19 @@ export function ProxyStatus() { ), }, + { + type: "content", + hidden: systemProxy.data?.supported !== true, + label: systemProxy.data != null && ( + { + dropdownRef.current?.close?.(); + setHelperDialogOpen(true); + }} + /> + ), + }, { type: "content", label: caStatus.data?.trusted ? ( @@ -253,7 +274,19 @@ export function ProxyStatus() { export function useProxySetup() { const queryClient = useQueryClient(); const caStatus = useRpcQuery("ca_status", {}); - const systemProxy = useRpcQuery("system_proxy_status", {}); + const registeredAt = useAtomValue(helperRegisteredAtAtom); + const systemProxy = useRpcQuery( + "system_proxy_status", + {}, + { + // The helper is approved in System Settings, out of the app's sight. + refetchInterval: (query) => + helperPollInterval(query.state.data?.helper, registeredAt, Date.now()), + }, + ); + // The user comes back from System Settings, maybe having just allowed the helper. + const { refetch: refetchSystemProxy } = systemProxy; + useEffect(() => onWindowFocus(() => void refetchSystemProxy()), [refetchSystemProxy]); const trust = useRpcMutation("ca_trust", { mutationKey: ["ca_trust"], onSuccess: (status) => queryClient.setQueryData(["ca_status", {}], status), @@ -279,9 +312,63 @@ export function useSystemProxySwitch(setup: ReturnType) { port: proxyState?.port ?? proxyState?.configuredPort ?? null, pending: setSystemProxy.isPending ? (setSystemProxy.variables?.enabled ?? null) : null, error: setSystemProxy.error?.message ?? null, + helper: systemProxy.data?.backend === "helper", }); } +/** The privileged helper: whether it is installed and approved, and which way changes go */ +function HelperMenuRow({ + status, + onOpenDialog, +}: { + status: SystemProxyStatus; + onOpenDialog: () => void; +}) { + const queryClient = useQueryClient(); + const unregister = useRpcMutation("helper_unregister", { + onSuccess: (status) => + queryClient.setQueryData(["system_proxy_status", {}], status), + }); + const row = helperRow(status.helper, status.backend); + const error = unregister.error; + return ( + + } + title={row.title} + description={ + error != null ? {error.message} : row.description + } + action={ + row.action != null && ( + + ) + } + /> + ); +} + /** Saves whether the system proxy turns on as the app launches */ function useSetSystemProxyAtLaunch() { const store = useStore(); diff --git a/apps/yaak-proxy/lib/atoms.ts b/apps/yaak-proxy/lib/atoms.ts index 040fbe629..291ddef95 100644 --- a/apps/yaak-proxy/lib/atoms.ts +++ b/apps/yaak-proxy/lib/atoms.ts @@ -14,5 +14,11 @@ export const selectedFlowIdAtom = atom((get) => get(selectedFlowAtom)?.id ?? nul /** The list follows new traffic, rather than being held where it was scrolled or selected */ export const followingAtom = atom(true); +/** When the helper was last registered, so its state is read until approval shows */ +export const helperRegisteredAtAtom = atom(null); + +/** The dialog that walks through allowing the helper in System Settings */ +export const helperDialogOpenAtom = atom(false); + /** Stacked by default; the header's toggle switches to side by side */ export const flowLayoutAtom = atomWithStorage("flow_layout", "vertical"); diff --git a/apps/yaak-proxy/lib/systemProxy.test.ts b/apps/yaak-proxy/lib/systemProxy.test.ts index ab9a6e3d8..c20437f6d 100644 --- a/apps/yaak-proxy/lib/systemProxy.test.ts +++ b/apps/yaak-proxy/lib/systemProxy.test.ts @@ -1,5 +1,12 @@ +import type { HelperState } from "@yaakapp-internal/proxy-lib"; import { describe, expect, test } from "vite-plus/test"; -import { recordingLabel, type SystemProxyState, systemProxySwitch } from "./systemProxy"; +import { + helperPollInterval, + helperRow, + recordingLabel, + type SystemProxyState, + systemProxySwitch, +} from "./systemProxy"; const off: SystemProxyState = { routing: false, port: 9090, pending: null, error: null }; @@ -25,6 +32,14 @@ describe("systemProxySwitch", () => { }); }); + test("waits for no password when the helper makes the change", () => { + expect(systemProxySwitch({ ...off, pending: false, helper: true })).toEqual({ + checked: false, + description: "Changing the system proxy", + failed: false, + }); + }); + test("snaps back on a cancelled prompt and says so", () => { expect(systemProxySwitch({ ...off, error: "Cancelled in the password dialog" })).toEqual({ checked: false, @@ -37,6 +52,107 @@ describe("systemProxySwitch", () => { }); }); +describe("helperRow", () => { + test("offers each next step, and says which way changes go", () => { + expect(helperRow("not_registered", "admin_prompt")).toMatchObject({ + title: "Helper: not installed", + action: "install", + }); + expect(helperRow("requires_approval", "admin_prompt")).toMatchObject({ + title: "Helper: awaiting approval", + action: "approve", + }); + const enabled = helperRow("enabled", "helper"); + expect(enabled).toMatchObject({ title: "Helper: enabled", action: "unregister" }); + expect(enabled.description).toMatch(/No password/); + }); + + test("says when the password prompt still has settings to put back", () => { + expect(helperRow("enabled", "admin_prompt").description).toMatch(/password prompt/); + }); + + test("offers to install when macOS can't find it, rather than blaming a dev build", () => { + expect(helperRow("not_found", "admin_prompt")).toMatchObject({ + title: "Helper: not found by macOS", + action: "install", + }); + }); + + test("keeps every row short", () => { + const states: HelperState[] = [ + "unavailable", + "not_found", + "not_registered", + "requires_approval", + "enabled", + ]; + for (const state of states) { + for (const backend of ["helper", "admin_prompt"] as const) { + const row = helperRow(state, backend); + expect(row.title.length).toBeLessThanOrEqual(30); + expect(row.description.length).toBeLessThanOrEqual(60); + } + } + }); + + test("has a titled row for every state the backend sends", () => { + // A missing key here fails the typecheck, so a new state can't be left out. + const states: Record = { + unavailable: true, + not_found: true, + not_registered: true, + requires_approval: true, + enabled: true, + }; + for (const state of Object.keys(states) as HelperState[]) { + for (const backend of ["helper", "admin_prompt", "sudo"] as const) { + expect(helperRow(state, backend).title).toMatch(/^Helper: \S/); + } + } + }); + + test("still gives a row for a state this build doesn't know", () => { + const row = helperRow("from_the_future" as HelperState, "admin_prompt"); + expect(row).toEqual({ + title: "Helper: from_the_future", + description: "Changes use the password prompt.", + action: null, + }); + }); + + test("says plainly that a dev build cannot use it", () => { + expect(helperRow("unavailable", "admin_prompt")).toMatchObject({ + title: "Helper: not in dev builds", + action: null, + }); + }); +}); + +describe("helperPollInterval", () => { + const now = 100_000; + + test("polls every 2 seconds while approval is pending", () => { + expect(helperPollInterval("requires_approval", null, now)).toBe(2000); + }); + + test("stops once enabled or not registered, even just after registering", () => { + expect(helperPollInterval("enabled", now - 500, now)).toBe(false); + expect(helperPollInterval("not_registered", now - 500, now)).toBe(false); + }); + + test("polls briefly after registering, until approval shows", () => { + expect(helperPollInterval("not_found", now - 500, now)).toBe(2000); + expect(helperPollInterval(undefined, now - 500, now)).toBe(2000); + expect(helperPollInterval("not_found", now - 10_000, now)).toBe(false); + }); + + test("never polls in other states", () => { + for (const helper of ["unavailable", "not_found", undefined] as const) { + expect(helperPollInterval(helper, null, now)).toBe(false); + } + }); +}); + describe("recordingLabel", () => { test("offers the opposite of the current state", () => { expect(recordingLabel(true)).toBe("Pause Recording"); diff --git a/apps/yaak-proxy/lib/systemProxy.ts b/apps/yaak-proxy/lib/systemProxy.ts index 031fad9b7..05a8c8093 100644 --- a/apps/yaak-proxy/lib/systemProxy.ts +++ b/apps/yaak-proxy/lib/systemProxy.ts @@ -1,7 +1,9 @@ +import type { HelperState, SystemProxyBackend } from "@yaakapp-internal/proxy-lib"; + /** * "Override system proxy": this Mac's web traffic pointed at the running engine, changed at once - * whenever it is flipped, through the macOS password prompt. Whether it turns on as the app - * launches is a separate setting. + * whenever it is flipped, through the privileged helper or the macOS password prompt. Whether it + * turns on as the app launches is a separate setting. */ export interface SystemProxyState { /** This Mac's web traffic points at the engine now */ @@ -12,6 +14,8 @@ export interface SystemProxyState { pending: boolean | null; /** The last change failed, with this message */ error: string | null; + /** Changes go through the helper, with no password prompt */ + helper?: boolean; } export interface SystemProxySwitch { @@ -24,7 +28,8 @@ export interface SystemProxySwitch { /** What the system proxy switch shows */ export function systemProxySwitch(state: SystemProxyState): SystemProxySwitch { if (state.pending != null) { - return { checked: state.pending, description: "Waiting for your password", failed: false }; + const description = state.helper ? "Changing the system proxy" : "Waiting for your password"; + return { checked: state.pending, description, failed: false }; } // A failed change leaves the switch where the system proxy actually is. const checked = state.routing; @@ -45,6 +50,85 @@ export function systemProxySwitch(state: SystemProxyState): SystemProxySwitch { }; } +export interface HelperRow { + title: string; + /** Which way changes go, and why */ + description: string; + /** What the row's button does, if it has one: the first two open the approval dialog */ + action: "install" | "approve" | "unregister" | null; +} + +/** The privileged helper's row in the state pill menu, kept short */ +export function helperRow(helper: HelperState, backend: SystemProxyBackend): HelperRow { + switch (helper) { + case "unavailable": + return { + title: "Helper: not in dev builds", + description: "Changes use the password prompt.", + action: null, + }; + case "not_found": + // What macOS reports until the helper is first installed. + return { + title: "Helper: not found by macOS", + description: "Changes use the password prompt.", + action: "install", + }; + case "not_registered": + return { + title: "Helper: not installed", + description: "Changes use the password prompt.", + action: "install", + }; + case "requires_approval": + return { + title: "Helper: awaiting approval", + description: "Changes use the password prompt.", + action: "approve", + }; + case "enabled": + return { + title: "Helper: enabled", + description: + backend === "helper" + ? "No password prompts." + : "One more password prompt puts back your settings.", + action: "unregister", + }; + default: { + // Every state is handled above; one this build doesn't know still gets a row, rather + // than taking the whole menu down. + const unknown: never = helper; + return { + title: `Helper: ${String(unknown)}`, + description: "Changes use the password prompt.", + action: null, + }; + } + } +} + +/** How often the helper's state is read while it can change outside the app */ +export const HELPER_POLL_MS = 2000; + +/** How long after registering the state is read even before it shows approval pending */ +export const HELPER_REGISTER_POLL_MS = 10_000; + +/** + * The interval to read the helper's state at: while it awaits approval in System Settings, and + * briefly after registering, until it settles as enabled or not registered. Never otherwise. + */ +export function helperPollInterval( + helper: HelperState | undefined, + registeredAt: number | null, + now: number, +): number | false { + if (helper === "requires_approval") return HELPER_POLL_MS; + if (helper === "enabled" || helper === "not_registered") return false; + const justRegistered = registeredAt != null && now - registeredAt < HELPER_REGISTER_POLL_MS; + return justRegistered ? HELPER_POLL_MS : false; +} + /** The menu item that pauses or resumes storing flows */ export function recordingLabel(recording: boolean): string { return recording ? "Pause Recording" : "Resume Recording"; diff --git a/apps/yaak-proxy/lib/tauri.ts b/apps/yaak-proxy/lib/tauri.ts index 063a54002..e4371eb89 100644 --- a/apps/yaak-proxy/lib/tauri.ts +++ b/apps/yaak-proxy/lib/tauri.ts @@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import { listen as tauriListen } from "@tauri-apps/api/event"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { type as tauriOsType } from "@tauri-apps/plugin-os"; +import { unsubscribeWhenReady } from "./unsubscribe"; /** Call a Tauri command. */ export function command(cmd: string, args?: Record): Promise { @@ -19,6 +20,16 @@ export function subscribe(event: string, callback: (payload: T) => void): () return () => unsub?.(); } +/** Calls `callback` each time the window regains focus. Returns an unsubscribe function. */ +export function onWindowFocus(callback: () => void): () => void { + const subscription = unsubscribeWhenReady( + getCurrentWebviewWindow().onFocusChanged( + ({ payload: focused }) => focused && subscription.active() && callback(), + ), + ); + return subscription.unsubscribe; +} + /** Show the current webview window. */ export function showWindow(): Promise { return getCurrentWebviewWindow().show(); diff --git a/apps/yaak-proxy/lib/unsubscribe.test.ts b/apps/yaak-proxy/lib/unsubscribe.test.ts new file mode 100644 index 000000000..0e02c65e2 --- /dev/null +++ b/apps/yaak-proxy/lib/unsubscribe.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test, vi } from "vite-plus/test"; +import { unsubscribeWhenReady } from "./unsubscribe"; + +function pending() { + let resolve: (fn: () => void) => void = () => {}; + const promise = new Promise<() => void>((r) => (resolve = r)); + return { promise, resolve }; +} + +describe("unsubscribeWhenReady", () => { + test("unsubscribes once ready when cleanup ran first", async () => { + const { promise, resolve } = pending(); + const unsub = vi.fn(); + const subscription = unsubscribeWhenReady(promise); + subscription.unsubscribe(); + expect(subscription.active()).toBe(false); + resolve(unsub); + await promise; + await Promise.resolve(); + expect(unsub).toHaveBeenCalledTimes(1); + }); + + test("unsubscribes at once when already ready, and only once", async () => { + const unsub = vi.fn(); + const promise = Promise.resolve(unsub); + const subscription = unsubscribeWhenReady(promise); + await promise; + await Promise.resolve(); + expect(subscription.active()).toBe(true); + subscription.unsubscribe(); + subscription.unsubscribe(); + expect(unsub).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/yaak-proxy/lib/unsubscribe.ts b/apps/yaak-proxy/lib/unsubscribe.ts new file mode 100644 index 000000000..c850965f8 --- /dev/null +++ b/apps/yaak-proxy/lib/unsubscribe.ts @@ -0,0 +1,25 @@ +/** + * An unsubscribe for a subscription that is still being set up. Called before the setup + * resolves, it unsubscribes as soon as it does; `active` says whether to still deliver events. + */ +export function unsubscribeWhenReady(subscription: Promise<() => void>): { + unsubscribe: () => void; + active: () => boolean; +} { + let unsub: (() => void) | null = null; + let cancelled = false; + subscription + .then((fn) => { + if (cancelled) fn(); + else unsub = fn; + }) + .catch(console.error); + return { + unsubscribe: () => { + cancelled = true; + unsub?.(); + unsub = null; + }, + active: () => !cancelled, + }; +} diff --git a/crates-proxy/yaak-proxy-lib/Cargo.toml b/crates-proxy/yaak-proxy-lib/Cargo.toml index d0bf5a144..fb6145358 100644 --- a/crates-proxy/yaak-proxy-lib/Cargo.toml +++ b/crates-proxy/yaak-proxy-lib/Cargo.toml @@ -31,6 +31,7 @@ yaak-http = { workspace = true } yaak-lifecycle = { workspace = true } yaak-models = { workspace = true } yaak-proxy = { workspace = true } +yaak-proxy-helper = { workspace = true, features = ["ts"] } yaak-rpc = { workspace = true } yaak-templates = { workspace = true } @@ -53,9 +54,16 @@ objc2-foundation = { version = "0.3.2", default-features = false, features = [ "std", "NSData", "NSDictionary", + "NSError", "NSGeometry", "NSString", ] } +objc2-service-management = { version = "0.3.2", default-features = false, features = [ + "std", + "objc2", + "objc2-foundation", + "SMAppService", +] } plist = "1.7" [dev-dependencies] @@ -68,3 +76,4 @@ hyper-util = { version = "0.1", features = ["tokio"] } reqwest = { workspace = true } tempfile = "3" tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "time"] } +yaak-proxy-helper = { workspace = true, features = ["test-support"] } diff --git a/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts b/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts index 68a3b5fa4..0ed90397d 100644 --- a/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts +++ b/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts @@ -179,6 +179,12 @@ otherOwner: OtherOwner | null, }; export type GlobalAction = "recording_toggle" | "flows_clear"; +export type HelperRegisterRequest = Record; + +export type HelperState = "unavailable" | "not_registered" | "requires_approval" | "enabled" | "not_found"; + +export type HelperUnregisterRequest = Record; + /** * The HTTP versions offered to clients in intercepted HTTPS. Upstream, the engine speaks * whichever the server picks either way. @@ -232,7 +238,7 @@ export type ProxyStatePayload = { state: ProxyState, }; export type RpcEventSchema = { flows_cleared: FlowsClearedPayload, model_write: ModelPayload, proxy_state_changed: ProxyStatePayload, }; -export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], set_recording: [SetRecordingRequest, boolean], set_system_proxy_at_launch: [SetSystemProxyAtLaunchRequest, boolean], set_http_version: [SetHttpVersionRequest, HttpVersion], app_icon: [AppIconRequest, string | null], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaStatus], clear_flows: [ClearFlowsRequest, number], export_har: [ExportHarRequest, number], filter_body: [FilterBodyRequest, FilterBodyResponse], get_flow_body: [GetFlowBodyRequest, GetFlowBodyResponse], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], list_workspaces: [ListWorkspacesRequest, ListWorkspacesResponse], preview_save: [PreviewSaveRequest, SavePreview], save_flow: [SaveFlowToYaakRequest, SavedFlow], system_proxy_set: [SystemProxySetRequest, SystemProxyStatus], system_proxy_status: [SystemProxyStatusRequest, SystemProxyStatus], take_over: [TakeOverRequest, boolean], }; +export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], set_recording: [SetRecordingRequest, boolean], set_system_proxy_at_launch: [SetSystemProxyAtLaunchRequest, boolean], set_http_version: [SetHttpVersionRequest, HttpVersion], app_icon: [AppIconRequest, string | null], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaStatus], clear_flows: [ClearFlowsRequest, number], 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], list_workspaces: [ListWorkspacesRequest, ListWorkspacesResponse], preview_save: [PreviewSaveRequest, SavePreview], save_flow: [SaveFlowToYaakRequest, SavedFlow], system_proxy_set: [SystemProxySetRequest, SystemProxyStatus], system_proxy_status: [SystemProxyStatusRequest, SystemProxyStatus], take_over: [TakeOverRequest, boolean], }; export type SaveFlowToYaakRequest = { flowId: string, workspaceId: string, folderId?: string, /** @@ -280,7 +286,12 @@ export type ServiceProxy = { /** * The network service, as System Settings names it: "Wi-Fi", "USB 10/100/1000 LAN". */ -name: string, http: ProxySetting, https: ProxySetting, }; +name: string, +/** + * What stays the same when the service is renamed: its device ("en0"), or its hardware + * port when it has none. Absent when `networksetup` lists neither. + */ +id?: string, http: ProxySetting, https: ProxySetting, }; export type SetHttpVersionRequest = { httpVersion: HttpVersion, }; @@ -304,6 +315,10 @@ enabled: boolean, * The port checked against. */ port: number, services: Array, backend: SystemProxyBackend, +/** + * The privileged helper's registration, which decides the backend. + */ +helper: HelperState, /** * This platform has a system proxy the app can set. */ diff --git a/crates-proxy/yaak-proxy-lib/src/helper.rs b/crates-proxy/yaak-proxy-lib/src/helper.rs new file mode 100644 index 000000000..3ef2988df --- /dev/null +++ b/crates-proxy/yaak-proxy-lib/src/helper.rs @@ -0,0 +1,222 @@ +//! The privileged helper from the app's side: its registration with launchd through +//! `SMAppService`, and requests over its socket. Only the bundled, signed app can register it +//! or be answered by it; `tauri dev` and the CLI see it as unavailable. + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export, export_to = "gen_rpc.ts")] +pub enum HelperState { + /// Not running from a bundle with the helper inside: a dev build, the CLI, or not macOS. + Unavailable, + NotRegistered, + /// Registered, and waiting for the user to allow it in System Settings. + RequiresApproval, + Enabled, + /// The app has the helper's plist, but `SMAppService` reports it not found. + NotFound, +} + +#[cfg(target_os = "macos")] +mod imp { + use super::HelperState; + use objc2::rc::Retained; + use objc2_foundation::NSString; + use objc2_service_management::{SMAppService, SMAppServiceStatus}; + use std::io::{BufRead, BufReader, Write}; + use std::os::unix::net::UnixStream; + use std::path::PathBuf; + use std::time::{Duration, Instant}; + use yaak_proxy_helper::protocol::{Request, Response, Status}; + + /// How long a just-approved daemon gets to open its socket. + const CONNECT_WAIT: Duration = Duration::from_secs(3); + + /// Long enough for `networksetup` on every service. + const IO_TIMEOUT: Duration = Duration::from_secs(30); + + /// The helper's plist inside this app, when it runs from a bundle that has one, so + /// `SMAppService` can find it. + fn plist_path() -> Option { + let exe = std::env::current_exe().ok()?; + let contents = exe.parent()?.parent()?; + let path = contents.join("Library/LaunchDaemons").join(yaak_proxy_helper::PLIST_NAME); + path.is_file().then_some(path) + } + + fn service() -> Option> { + plist_path()?; + let plist = NSString::from_str(yaak_proxy_helper::PLIST_NAME); + // SAFETY: a class method taking a valid NSString. + Some(unsafe { SMAppService::daemonServiceWithPlistName(&plist) }) + } + + fn raw_status(service: &SMAppService) -> SMAppServiceStatus { + // SAFETY: reads the service's registration status. + unsafe { service.status() } + } + + fn state_of(service: &SMAppService) -> HelperState { + match raw_status(service) { + SMAppServiceStatus::Enabled => HelperState::Enabled, + SMAppServiceStatus::RequiresApproval => HelperState::RequiresApproval, + SMAppServiceStatus::NotRegistered => HelperState::NotRegistered, + _ => HelperState::NotFound, + } + } + + fn status_name(status: SMAppServiceStatus) -> &'static str { + match status { + SMAppServiceStatus::NotRegistered => "notRegistered", + SMAppServiceStatus::Enabled => "enabled", + SMAppServiceStatus::RequiresApproval => "requiresApproval", + SMAppServiceStatus::NotFound => "notFound", + _ => "unknown", + } + } + + pub fn state() -> HelperState { + let Some(plist) = plist_path() else { + log::debug!("No helper plist in this app, so the helper is unavailable"); + return HelperState::Unavailable; + }; + let Some(service) = service() else { + return HelperState::Unavailable; + }; + let status = raw_status(&service); + log::debug!( + "SMAppService status {} ({}) for {}", + status.0, + status_name(status), + plist.display() + ); + state_of(&service) + } + + /// What `SMAppService` says about the helper, for `--helper-status`. + pub fn diagnose() -> String { + let exe = std::env::current_exe() + .map(|p| p.display().to_string()) + .unwrap_or_else(|e| format!("unknown ({e})")); + let mut lines = vec![format!("executable: {exe}")]; + match plist_path() { + Some(plist) => lines.push(format!("plist: {}", plist.display())), + None => lines.push(format!( + "plist: none at Contents/Library/LaunchDaemons/{}", + yaak_proxy_helper::PLIST_NAME + )), + } + if let Some(service) = service() { + let status = raw_status(&service); + lines.push(format!("SMAppService status: {} ({})", status.0, status_name(status))); + } + lines.push(format!("state: {:?}", state())); + lines.join("\n") + } + + /// Registers the daemon and, while it awaits approval, opens the System Settings pane + /// that allows it. + pub fn register() -> Result { + let service = service().ok_or_else(unavailable)?; + // SAFETY: registers the plist bundled with this app. + let registered = unsafe { service.registerAndReturnError() }; + let state = state_of(&service); + match state { + // Registering asks for approval by failing with "Operation not permitted". + HelperState::RequiresApproval => { + // SAFETY: opens System Settings; no arguments. + unsafe { SMAppService::openSystemSettingsLoginItems() }; + Ok(state) + } + HelperState::Enabled => Ok(state), + _ => Err(registered + .err() + .map(|e| format!("Failed to install the helper: {}", e.localizedDescription())) + .unwrap_or_else(|| "Failed to install the helper".to_string())), + } + } + + /// Removes the daemon, once it has put the system proxy back. + pub fn unregister() -> Result { + let service = service().ok_or_else(unavailable)?; + // A helper that has not put the settings back stays registered, so launchd restarts it + // and it can try again; removed, nothing could restore them. + if state_of(&service) == HelperState::Enabled { + request(&Request::Unregister).map_err(|e| { + format!( + "The helper could not restore your proxy settings, so it was kept: {e}. Try \ + again in a moment." + ) + })?; + } + // SAFETY: unregisters the plist bundled with this app. + unsafe { service.unregisterAndReturnError() } + .map_err(|e| format!("Failed to remove the helper: {}", e.localizedDescription()))?; + Ok(state_of(&service)) + } + + fn unavailable() -> String { + "The helper is only available in the installed app, not a dev build".to_string() + } + + /// Sends one request and reads its response. + pub fn request(request: &Request) -> Result { + let start = Instant::now(); + let stream = loop { + match UnixStream::connect(yaak_proxy_helper::SOCKET_PATH) { + Ok(stream) => break stream, + Err(_) if start.elapsed() < CONNECT_WAIT => { + std::thread::sleep(Duration::from_millis(200)); + } + Err(e) => return Err(format!("The helper is not running: {e}")), + } + }; + exchange(stream, request) + } + + fn exchange(stream: UnixStream, request: &Request) -> Result { + let _ = stream.set_read_timeout(Some(IO_TIMEOUT)); + let _ = stream.set_write_timeout(Some(IO_TIMEOUT)); + let mut line = serde_json::to_string(request).map_err(|e| e.to_string())?; + line.push('\n'); + (&stream).write_all(line.as_bytes()).map_err(|e| format!("The helper hung up: {e}"))?; + let mut reply = String::new(); + BufReader::new(&stream) + .read_line(&mut reply) + .map_err(|e| format!("The helper did not answer: {e}"))?; + let response: Response = serde_json::from_str(&reply) + .map_err(|e| format!("The helper's answer was not understood: {e}"))?; + response.into_result() + } + + /// Points the system proxy at `port`, or puts back what was there, owned by this process. + pub fn set(enabled: bool, port: u16) -> Result<(), String> { + let owner_pid = std::process::id() as i32; + request(&Request::Set { enabled, port, owner_pid }).map(|_| ()) + } +} + +#[cfg(target_os = "macos")] +pub use imp::{diagnose, register, set, state, unregister}; + +#[cfg(not(target_os = "macos"))] +pub fn state() -> HelperState { + HelperState::Unavailable +} + +#[cfg(not(target_os = "macos"))] +pub fn diagnose() -> String { + "The helper is only available on macOS".to_string() +} + +#[cfg(not(target_os = "macos"))] +pub fn register() -> Result { + Err("The helper is only available on macOS".to_string()) +} + +#[cfg(not(target_os = "macos"))] +pub fn unregister() -> Result { + Ok(HelperState::Unavailable) +} diff --git a/crates-proxy/yaak-proxy-lib/src/lib.rs b/crates-proxy/yaak-proxy-lib/src/lib.rs index 45897614f..6ccf2a9ad 100644 --- a/crates-proxy/yaak-proxy-lib/src/lib.rs +++ b/crates-proxy/yaak-proxy-lib/src/lib.rs @@ -8,6 +8,7 @@ pub mod convert; pub mod db; pub mod flows; pub mod har; +pub mod helper; pub mod macos; pub mod models; pub mod save; @@ -20,6 +21,7 @@ use crate::client::ControlClient; use crate::control::{ControlFile, ControlInfo, ControlServer, Owner}; use crate::convert::DroppedHeader; use crate::db::ProxyQueryManager; +use crate::helper::HelperState; use crate::macos::{SystemProxyBackend, SystemProxyStatus}; use crate::models::{Flow, FlowBody, FlowIden, ModelPayload, ProxyHeader, find_header}; use crate::save::{ClientDb, SaveFlowRequest, SaveFlowResponse, WorkspaceSummary}; @@ -364,18 +366,63 @@ impl ProxyCtx { } } + /// The helper once it is enabled; until then, and while the password dialog still has the + /// user's settings to put back, the dialog. The CLI daemon always uses `sudo`. pub fn system_proxy_backend(&self) -> SystemProxyBackend { + self.backend_for(self.helper_state()) + } + + fn backend_for(&self, helper: HelperState) -> SystemProxyBackend { match self.owner { - Owner::App => SystemProxyBackend::AdminPrompt, Owner::Cli => SystemProxyBackend::Sudo, + // What the dialog saved only it can restore, as the helper never saw it: settings + // for routing it turned on, and settings parked for a service that was missing, + // which its next capture uses if the service is back. + Owner::App if helper == HelperState::Enabled && !self.snapshot_path().exists() => { + SystemProxyBackend::Helper + } + Owner::App => SystemProxyBackend::AdminPrompt, } } + /// The helper's registration, as far as this process can use it. + pub fn helper_state(&self) -> HelperState { + match self.owner { + Owner::App => helper::state(), + Owner::Cli => HelperState::Unavailable, + } + } + + /// Where the password dialog and `sudo` keep the settings they replaced. + fn snapshot_path(&self) -> PathBuf { + self.data_dir.join(macos::SNAPSHOT_FILE) + } + /// Judged against the running engine's port, or the configured one while it is stopped. /// Blocks. pub fn system_proxy_status(&self) -> Result { let port = self.port().unwrap_or(self.settings()?.port); - macos::status(port, self.system_proxy_backend()) + let helper = self.helper_state(); + macos::status(port, self.backend_for(helper), helper) + } + + /// Registers the helper, opening System Settings for the user to allow it. Blocks. + pub fn register_helper(&self) -> Result { + helper::register()?; + self.system_proxy_status() + } + + /// Removes the helper, which first puts back the settings it replaced, so the system proxy + /// is off afterwards. Blocks. + pub fn unregister_helper(&self) -> Result { + let turn = self.system_proxy_turn.lock().map_err(|_| "lock poisoned".to_string())?; + let restores = self.system_proxy_backend() == SystemProxyBackend::Helper; + helper::unregister()?; + if restores { + self.system_proxy_on.store(false, Ordering::SeqCst); + } + drop(turn); + self.system_proxy_status() } /// Points this Mac's web traffic at the running engine, or stops, at once, through the @@ -465,14 +512,15 @@ impl ProxyCtx { return Ok(()); } } - macos::set(enabled, port, self.system_proxy_backend()) + macos::set(enabled, port, self.system_proxy_backend(), &self.snapshot_path()) } /// Turns off a system proxy left pointing at one of `ports` by an owner that crashed, so - /// the Mac is not left offline. Leaves it alone when something is serving on the port. - /// Blocks. + /// the Mac is not left offline, putting back the settings it replaced when they were + /// saved. Leaves it alone when something is serving on the port. Blocks. pub fn reset_stale_system_proxy(&self, ports: &[u16]) -> Result<(), String> { let mut ports = ports.to_vec(); + ports.extend(macos::saved_port(&self.snapshot_path())); ports.sort_unstable(); ports.dedup(); for port in ports { @@ -486,7 +534,7 @@ impl ProxyCtx { } // Covers disabled services too, and asks for nothing when none point at the port. debug!("Turning off any stale system proxy on port {port}"); - macos::set(false, port, self.system_proxy_backend())?; + macos::set(false, port, self.system_proxy_backend(), &self.snapshot_path())?; } Ok(()) } @@ -863,6 +911,14 @@ pub struct SystemProxySetRequest { pub enabled: bool, } +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct HelperRegisterRequest {} + +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct HelperUnregisterRequest {} + // -- Handlers -- fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result { @@ -1096,6 +1152,20 @@ async fn system_proxy_set( blocking(move || ctx.set_system_proxy(req.enabled)).await } +async fn helper_register( + ctx: ProxyCtx, + _req: HelperRegisterRequest, +) -> Result { + blocking(move || ctx.register_helper()).await +} + +async fn helper_unregister( + ctx: ProxyCtx, + _req: HelperUnregisterRequest, +) -> Result { + blocking(move || ctx.unregister_helper()).await +} + async fn blocking( f: impl FnOnce() -> Result + Send + 'static, ) -> Result { @@ -1362,6 +1432,8 @@ define_rpc! { filter_body(FilterBodyRequest) -> FilterBodyResponse, get_flow_body(GetFlowBodyRequest) -> GetFlowBodyResponse, get_proxy_state(GetProxyStateRequest) -> GetProxyStateResponse, + helper_register(HelperRegisterRequest) -> SystemProxyStatus, + helper_unregister(HelperUnregisterRequest) -> SystemProxyStatus, list_workspaces(ListWorkspacesRequest) -> ListWorkspacesResponse, preview_save(PreviewSaveRequest) -> SavePreview, save_flow(SaveFlowToYaakRequest) -> SavedFlow, @@ -1380,6 +1452,27 @@ define_rpc! { mod tests { use super::*; + #[test] + fn settings_the_password_prompt_parked_keep_it_over_the_helper() { + use yaak_proxy_helper::networksetup::{ProxySetting, ServiceProxy, Snapshot}; + let dir = tempfile::tempdir().unwrap(); + let (events, _rx) = RpcEventEmitter::new(); + let ctx = ProxyCtx::new(dir.path(), events).unwrap(); + assert_eq!(ctx.backend_for(HelperState::Enabled), SystemProxyBackend::Helper); + + // A restore parked an unplugged adapter's settings, at port 0. + let corp = ProxySetting { enabled: true, server: "corp-proxy".into(), port: 3128 }; + let parked = Snapshot { + services: vec![ServiceProxy::new("USB LAN", corp.clone(), corp)], + ..Snapshot::default() + }; + std::fs::write(ctx.snapshot_path(), serde_json::to_vec(&parked).unwrap()).unwrap(); + assert_eq!(macos::saved_port(&ctx.snapshot_path()), None, "routes nothing"); + // Only the password prompt's capture would use them when the adapter is back. + assert_eq!(ctx.backend_for(HelperState::Enabled), SystemProxyBackend::AdminPrompt); + assert_eq!(ctx.backend_for(HelperState::NotRegistered), SystemProxyBackend::AdminPrompt); + } + #[test] fn a_request_is_recorded_by_the_state_when_it_started() { let mut log = RecordingLog::default(); diff --git a/crates-proxy/yaak-proxy-lib/src/macos.rs b/crates-proxy/yaak-proxy-lib/src/macos.rs index 931087dbd..7a319306b 100644 --- a/crates-proxy/yaak-proxy-lib/src/macos.rs +++ b/crates-proxy/yaak-proxy-lib/src/macos.rs @@ -1,17 +1,26 @@ //! The macOS system proxy: `networksetup` pointing the web and secure web proxies of every //! enabled network service at `127.0.0.1:`. Reading needs no rights; changing needs an -//! administrator, so changes run as one script through the backend in use. Everything here -//! blocks, the password dialog included, so async callers go through `spawn_blocking`. +//! administrator, which the privileged helper is, and which the password dialog and `sudo` +//! borrow for one script. Each path saves the settings it replaces and puts them back when +//! turned off. Everything here blocks, the password dialog included, so async callers go +//! through `spawn_blocking`. +use crate::helper::HelperState; use serde::{Deserialize, Serialize}; +use std::path::Path; use ts_rs::TS; +use yaak_proxy_helper::networksetup::{ + NETWORKSETUP, Networksetup, Snapshot, disable_commands, enable_commands, read_services, +}; + +pub use yaak_proxy_helper::networksetup::{ProxySetting, ServiceProxy}; /// How a change gets administrator rights. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] #[serde(rename_all = "snake_case")] #[ts(export, export_to = "gen_rpc.ts")] pub enum SystemProxyBackend { - /// The privileged helper, approved once in System Settings (not built yet). + /// The privileged helper, approved once in System Settings. No prompt. Helper, /// The standard macOS password dialog, through `osascript`. AdminPrompt, @@ -29,101 +38,118 @@ pub struct SystemProxyStatus { pub port: u16, pub services: Vec, pub backend: SystemProxyBackend, + /// The privileged helper's registration, which decides the backend. + pub helper: HelperState, /// This platform has a system proxy the app can set. pub supported: bool, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "gen_rpc.ts")] -pub struct ServiceProxy { - /// The network service, as System Settings names it: "Wi-Fi", "USB 10/100/1000 LAN". - pub name: String, - pub http: ProxySetting, - pub https: ProxySetting, -} +/// Where the password dialog and `sudo` save the settings they replace, in the proxy data +/// directory. The helper keeps its own, as root. +pub const SNAPSHOT_FILE: &str = "system-proxy-snapshot.json"; -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "gen_rpc.ts")] -pub struct ProxySetting { - pub enabled: bool, - pub server: String, - pub port: u16, -} - -const HOST: &str = "127.0.0.1"; - -impl ProxySetting { - fn points_at(&self, port: u16) -> bool { - self.enabled && self.server == HOST && self.port == port +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn load_snapshot(path: &Path) -> Result, String> { + match std::fs::read(path) { + Ok(bytes) => serde_json::from_slice(&bytes) + .map(Some) + .map_err(|e| format!("Failed to read {}: {e}", path.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(format!("Failed to read {}: {e}", path.display())), } } -impl ServiceProxy { - /// Either proxy of the service sends traffic to `127.0.0.1:`. - pub fn points_at(&self, port: u16) -> bool { - self.http.points_at(port) || self.https.points_at(port) +/// The port the password dialog or `sudo` last pointed the system proxy at, while they have +/// settings saved to put back. +pub fn saved_port(snapshot_path: &Path) -> Option { + // Port 0 holds only settings no service matched, which route nothing. + load_snapshot(snapshot_path).ok().flatten().map(|s| s.port).filter(|p| *p != 0) +} + +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn save_snapshot(path: &Path, snapshot: &Snapshot) -> Result<(), String> { + let json = serde_json::to_vec_pretty(snapshot).map_err(|e| e.to_string())?; + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, json) + .and_then(|()| std::fs::rename(&tmp, path)) + .map_err(|e| format!("Failed to save {}: {e}", path.display())) +} + +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn remove_snapshot(path: &Path) -> Result<(), String> { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!("Failed to delete {}: {e}", path.display())), } } -/// The services from `networksetup -listallnetworkservices`, the disabled ones too with -/// `include_disabled`. The first line is a legend; a leading `*` marks a disabled service. +/// The password-dialog and `sudo` paths: reads through `ns`, and changes through `run`, which +/// gets administrator rights for the whole list at once. Turning on saves the settings it +/// replaces at `snapshot_path` first; turning off puts them back and deletes the file. #[cfg_attr(not(target_os = "macos"), allow(dead_code))] -fn parse_services(output: &str, include_disabled: bool) -> Vec { - output - .lines() - .skip(1) - .map(str::trim) - .filter(|l| !l.is_empty() && (include_disabled || !l.starts_with('*'))) - .map(|l| l.trim_start_matches('*').to_string()) - .collect() -} - -/// The output of `networksetup -getwebproxy` or `-getsecurewebproxy`. -#[cfg_attr(not(target_os = "macos"), allow(dead_code))] -fn parse_proxy(output: &str) -> ProxySetting { - let mut setting = ProxySetting::default(); - for line in output.lines() { - let Some((key, value)) = line.split_once(':') else { - continue; - }; - let value = value.trim(); - match key.trim() { - "Enabled" => setting.enabled = value.eq_ignore_ascii_case("yes"), - "Server" => setting.server = value.to_string(), - "Port" => setting.port = value.parse().unwrap_or_default(), - _ => {} +fn set_scripted( + ns: &impl Networksetup, + run: impl FnOnce(&[Vec]) -> Result<(), String>, + enabled: bool, + port: u16, + snapshot_path: &Path, +) -> Result<(), String> { + if enabled { + let services = read_services(ns, false)?; + let saved = load_snapshot(snapshot_path)?; + let mut snapshot = saved.clone().unwrap_or_default(); + snapshot.extend(&services, port); + // Saved before anything changes, so a crash after the change still restores. + save_snapshot(snapshot_path, &snapshot)?; + if let Err(e) = run(&enable_commands(&services, port)) { + // Usually cancelled, with nothing changed, and a snapshot kept would go stale, so + // what was there before goes back. Once anything points at us, it is what puts the + // rest back. + let changed = read_services(ns, false)?.iter().any(|s| s.points_at(port)); + if !changed { + match saved { + Some(saved) => save_snapshot(snapshot_path, &saved)?, + None => remove_snapshot(snapshot_path)?, + } + } + return Err(e); } + return Ok(()); } - setting -} -/// The `networksetup` calls that point `services` at `port`, or turn off those pointing at it. -#[cfg_attr(not(target_os = "macos"), allow(dead_code))] -fn commands(services: &[ServiceProxy], enabled: bool, port: u16) -> Vec> { - let mut commands = Vec::new(); - for service in services { - let name = service.name.clone(); - if enabled { - let port = port.to_string(); - for (set, state) in [ - ("-setwebproxy", "-setwebproxystate"), - ("-setsecurewebproxy", "-setsecurewebproxystate"), - ] { - commands.push(vec![set.into(), name.clone(), HOST.into(), port.clone()]); - commands.push(vec![state.into(), name.clone(), "on".into()]); - } - } else { - if service.http.points_at(port) { - commands.push(vec!["-setwebproxystate".into(), name.clone(), "off".into()]); - } - if service.https.points_at(port) { - commands.push(vec!["-setsecurewebproxystate".into(), name, "off".into()]); - } - } + // Disabled services too: one disabled since it was pointed at us would point at us again + // once re-enabled. + let services = read_services(ns, true)?; + let mut snapshot = load_snapshot(snapshot_path)?; + let (commands, used_up) = disable_commands(&services, snapshot.as_ref(), port); + if commands.is_empty() && !used_up { + return Ok(()); + } + // Saved first, so a restore cut short is finished by the next one. + if let Some(snapshot) = snapshot.as_mut() { + snapshot.mark_restoring(&commands, &services); + save_snapshot(snapshot_path, snapshot)?; + } + if !commands.is_empty() { + run(&commands)?; + } + match snapshot { + Some(snapshot) if used_up => match snapshot.leftover(&services) { + // Kept for when the service is back, and merged into the next capture. + Some(leftover) => { + let names: Vec<&str> = leftover.services.iter().map(|s| s.name.as_str()).collect(); + log::warn!("Keeping saved proxy settings no service matched: {}", names.join(", ")); + save_snapshot(snapshot_path, &leftover) + } + None => remove_snapshot(snapshot_path), + }, + Some(mut snapshot) => { + snapshot.restoring.clear(); + save_snapshot(snapshot_path, &snapshot) + } + None => Ok(()), } - commands } /// The calls as one `sh` script that stops at the first failure. @@ -152,23 +178,11 @@ fn applescript_string(s: &str) -> String { format!("\"{}\"", s.replace('\\', r"\\").replace('"', "\\\"")) } -#[cfg_attr(not(target_os = "macos"), allow(dead_code))] -const NETWORKSETUP: &str = "/usr/sbin/networksetup"; - #[cfg(target_os = "macos")] mod imp { use super::*; use std::process::{Command, Output, Stdio}; - fn networksetup(args: &[&str]) -> Result { - let output = Command::new(NETWORKSETUP) - .args(args) - .output() - .map_err(|e| format!("Failed to run {NETWORKSETUP}: {e}"))?; - check(&output, NETWORKSETUP)?; - Ok(String::from_utf8_lossy(&output.stdout).into_owned()) - } - fn check(output: &Output, program: &str) -> Result<(), String> { if output.status.success() { return Ok(()); @@ -181,21 +195,10 @@ mod imp { }) } - pub fn services(include_disabled: bool) -> Result, String> { - parse_services(&networksetup(&["-listallnetworkservices"])?, include_disabled) - .into_iter() - .map(|name| { - let http = parse_proxy(&networksetup(&["-getwebproxy", &name])?); - let https = parse_proxy(&networksetup(&["-getsecurewebproxy", &name])?); - Ok(ServiceProxy { name, http, https }) - }) - .collect() - } - pub fn run_privileged(script: &str, backend: SystemProxyBackend) -> Result<(), String> { match backend { SystemProxyBackend::Helper => { - Err("The privileged helper is not available yet".to_string()) + Err("The helper changes the system proxy itself, not by script".to_string()) } SystemProxyBackend::AdminPrompt => { let source = format!( @@ -228,36 +231,67 @@ mod imp { /// Which services point where, judged against `port`. #[cfg(target_os = "macos")] -pub fn status(port: u16, backend: SystemProxyBackend) -> Result { - let services = imp::services(false)?; +pub fn status( + port: u16, + backend: SystemProxyBackend, + helper: HelperState, +) -> Result { + let ns = yaak_proxy_helper::networksetup::SystemNetworksetup; + let services = read_services(&ns, false)?; let enabled = !services.is_empty() && services.iter().all(|s| s.http.points_at(port) && s.https.points_at(port)); - Ok(SystemProxyStatus { enabled, port, services, backend, supported: true }) + Ok(SystemProxyStatus { enabled, port, services, backend, helper, supported: true }) } -/// Points every enabled service at `127.0.0.1:`, or turns off the services that point -/// there, disabled ones included, leaving other proxies alone. Turning off with nothing -/// pointing at `port` does nothing. +/// Points every enabled service at `127.0.0.1:`, or puts back the settings that were +/// there, turning off any other proxy still pointing at `port`. Turning off with nothing +/// pointing at `port` asks for nothing. `snapshot_path` is where the password dialog and +/// `sudo` keep the settings they replace. #[cfg(target_os = "macos")] -pub fn set(enabled: bool, port: u16, backend: SystemProxyBackend) -> Result<(), String> { +pub fn set( + enabled: bool, + port: u16, + backend: SystemProxyBackend, + snapshot_path: &Path, +) -> Result<(), String> { if port == 0 { return Err("The system proxy needs a port".to_string()); } - // A service disabled since it was pointed at us would point at us again once re-enabled. - let commands = commands(&imp::services(!enabled)?, enabled, port); - if commands.is_empty() { - return Ok(()); + match backend { + SystemProxyBackend::Helper => crate::helper::set(enabled, port), + SystemProxyBackend::AdminPrompt | SystemProxyBackend::Sudo => set_scripted( + &yaak_proxy_helper::networksetup::SystemNetworksetup, + |commands| imp::run_privileged(&script(commands), backend), + enabled, + port, + snapshot_path, + ), } - imp::run_privileged(&script(&commands), backend) } #[cfg(not(target_os = "macos"))] -pub fn status(port: u16, backend: SystemProxyBackend) -> Result { - Ok(SystemProxyStatus { enabled: false, port, services: Vec::new(), backend, supported: false }) +pub fn status( + port: u16, + backend: SystemProxyBackend, + helper: HelperState, +) -> Result { + Ok(SystemProxyStatus { + enabled: false, + port, + services: Vec::new(), + backend, + helper, + supported: false, + }) } #[cfg(not(target_os = "macos"))] -pub fn set(enabled: bool, _port: u16, _backend: SystemProxyBackend) -> Result<(), String> { +pub fn set( + enabled: bool, + _port: u16, + _backend: SystemProxyBackend, + _snapshot_path: &Path, +) -> Result<(), String> { if !enabled { return Ok(()); } @@ -267,54 +301,181 @@ pub fn set(enabled: bool, _port: u16, _backend: SystemProxyBackend) -> Result<() #[cfg(test)] mod tests { use super::*; + use yaak_proxy_helper::networksetup::HOST; + use yaak_proxy_helper::networksetup::fake::FakeNetworksetup; fn setting(enabled: bool, server: &str, port: u16) -> ProxySetting { ProxySetting { enabled, server: server.into(), port } } - #[test] - fn parses_services() { - let output = "An asterisk (*) denotes that a network service is disabled.\nWi-Fi\n*Thunderbolt Bridge\nUSB 10/100/1000 LAN\n\n"; - assert_eq!(parse_services(output, false), vec!["Wi-Fi", "USB 10/100/1000 LAN"]); - // A disabled service can still hold a proxy setting pointing at us. - assert_eq!( - parse_services(output, true), - vec!["Wi-Fi", "Thunderbolt Bridge", "USB 10/100/1000 LAN"] - ); + /// Runs the "privileged" commands against the fake, counting the prompts. + fn runner<'a>( + ns: &'a FakeNetworksetup, + prompts: &'a std::cell::Cell, + ) -> impl FnOnce(&[Vec]) -> Result<(), String> + 'a { + move |commands| { + prompts.set(prompts.get() + 1); + yaak_proxy_helper::networksetup::apply(ns, commands) + } + } + + fn fake() -> FakeNetworksetup { + FakeNetworksetup::new(vec![ + ("Wi-Fi", setting(true, "corp-proxy", 3128), setting(false, "corp-proxy", 3129)), + ("Ethernet", ProxySetting::default(), ProxySetting::default()), + ]) } #[test] - fn parses_proxy() { - let on = "Enabled: Yes\nServer: 127.0.0.1\nPort: 9090\nAuthenticated Proxy Enabled: 0\n"; - assert_eq!(parse_proxy(on), setting(true, "127.0.0.1", 9090)); - let off = "Enabled: No\nServer: \nPort: 0\nAuthenticated Proxy Enabled: 0\n"; - assert_eq!(parse_proxy(off), setting(false, "", 0)); + fn the_password_path_restores_what_it_replaced() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(SNAPSHOT_FILE); + let ns = fake(); + let before = ns.proxies(); + let prompts = std::cell::Cell::new(0); + + set_scripted(&ns, runner(&ns, &prompts), true, 9090, &path).unwrap(); + assert!(ns.proxies().iter().all(|s| s.http.points_at(9090) && s.https.points_at(9090))); + assert!(load_snapshot(&path).unwrap().is_some()); + + set_scripted(&ns, runner(&ns, &prompts), false, 9090, &path).unwrap(); + assert_eq!(ns.proxies()[0], before[0]); + assert!(!ns.proxies()[1].points_at(9090)); + assert!(!path.exists()); + assert_eq!(prompts.get(), 2); } #[test] - fn turning_off_touches_only_our_proxies() { - let services = vec![ - ServiceProxy { - name: "Wi-Fi".into(), - http: setting(true, "127.0.0.1", 9090), - https: setting(true, "127.0.0.1", 9090), - }, - // Someone else's proxy, and ours on another port. - ServiceProxy { - name: "Ethernet".into(), - http: setting(true, "corp-proxy", 9090), - https: setting(true, "127.0.0.1", 8080), - }, - ]; - assert!(services[0].points_at(9090) && !services[1].points_at(9090)); - assert_eq!( - commands(&services, false, 9090), - vec![ - vec!["-setwebproxystate", "Wi-Fi", "off"], - vec!["-setsecurewebproxystate", "Wi-Fi", "off"], - ] - ); - assert_eq!(commands(&services, true, 9090).len(), 8); + fn a_stale_reset_after_a_crash_uses_the_snapshot() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(SNAPSHOT_FILE); + let ns = fake(); + let before = ns.proxies(); + let prompts = std::cell::Cell::new(0); + set_scripted(&ns, runner(&ns, &prompts), true, 9090, &path).unwrap(); + + // The app crashed; the next launch resets the configured port, now another one, which + // leaves the snapshot alone, and then the port it saved. + assert_eq!(saved_port(&path), Some(9090)); + set_scripted(&ns, runner(&ns, &prompts), false, 8080, &path).unwrap(); + assert!(path.exists()); + set_scripted(&ns, runner(&ns, &prompts), false, 9090, &path).unwrap(); + assert_eq!(ns.proxies()[0], before[0]); + assert!(!path.exists()); + } + + #[test] + fn a_stale_reset_at_an_old_port_leaves_live_routing_alone() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(SNAPSHOT_FILE); + let ns = fake(); + let before = ns.proxies(); + let prompts = std::cell::Cell::new(0); + // A crash left a disabled service on 9090; the new engine routes on 9191. + set_scripted(&ns, runner(&ns, &prompts), true, 9090, &path).unwrap(); + ns.set_enabled("Ethernet", false); + set_scripted(&ns, runner(&ns, &prompts), true, 9191, &path).unwrap(); + + set_scripted(&ns, runner(&ns, &prompts), false, 9090, &path).unwrap(); + assert!(ns.proxies()[0].http.points_at(9191), "live routing kept"); + assert!(!ns.proxies()[1].points_at(9090), "the stale port cleared"); + assert!(path.exists()); + + set_scripted(&ns, runner(&ns, &prompts), false, 9191, &path).unwrap(); + assert_eq!(ns.proxies()[0], before[0]); + assert!(!path.exists()); + } + + #[test] + fn the_password_path_restores_a_renamed_service_and_keeps_an_unplugged_one() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(SNAPSHOT_FILE); + let ns = fake(); + let before = ns.proxies(); + let prompts = std::cell::Cell::new(0); + set_scripted(&ns, runner(&ns, &prompts), true, 9090, &path).unwrap(); + ns.rename("Wi-Fi", "Office Wi-Fi"); + ns.remove("Ethernet"); + + set_scripted(&ns, runner(&ns, &prompts), false, 9090, &path).unwrap(); + let wifi = &ns.proxies()[0]; + assert_eq!((&wifi.http, &wifi.https), (&before[0].http, &before[0].https)); + // Ethernet's settings stay saved, without counting as routing. + let kept = load_snapshot(&path).unwrap().unwrap(); + assert_eq!(kept.services.len(), 1); + assert_eq!(kept.services[0].name, "Ethernet"); + assert_eq!(saved_port(&path), None); + } + + #[test] + fn a_partly_applied_change_keeps_its_snapshot() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(SNAPSHOT_FILE); + let ns = fake(); + let before = ns.proxies(); + let prompts = std::cell::Cell::new(0); + // Wi-Fi is changed, then Ethernet fails. + *ns.failing.lock().unwrap() = Some("Ethernet".into()); + assert!(set_scripted(&ns, runner(&ns, &prompts), true, 9090, &path).is_err()); + assert!(ns.proxies()[0].points_at(9090)); + assert!(path.exists()); + + *ns.failing.lock().unwrap() = None; + set_scripted(&ns, runner(&ns, &prompts), false, 9090, &path).unwrap(); + assert_eq!(ns.proxies()[0], before[0]); + } + + #[test] + fn turning_off_with_nothing_pointing_at_us_asks_for_nothing() { + 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), false, 9090, &path).unwrap(); + assert_eq!(prompts.get(), 0); + assert!(ns.changes.lock().unwrap().is_empty()); + } + + #[test] + fn a_cancelled_prompt_keeps_no_snapshot() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(SNAPSHOT_FILE); + let ns = fake(); + let cancel = |_: &[Vec]| Err("Cancelled in the password dialog".to_string()); + assert!(set_scripted(&ns, cancel, true, 9090, &path).is_err()); + assert!(!path.exists()); + assert!(ns.changes.lock().unwrap().is_empty()); + } + + #[test] + fn turning_on_again_keeps_the_first_snapshot() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(SNAPSHOT_FILE); + let ns = fake(); + let before = ns.proxies(); + let prompts = std::cell::Cell::new(0); + set_scripted(&ns, runner(&ns, &prompts), true, 9090, &path).unwrap(); + // A second enable, the engine on a new port, must not save ours as the user's. + set_scripted(&ns, runner(&ns, &prompts), true, 9191, &path).unwrap(); + let cancel = |_: &[Vec]| Err("Cancelled in the password dialog".to_string()); + assert!(set_scripted(&ns, cancel, true, 9191, &path).is_err()); + assert!(path.exists(), "a cancelled re-enable keeps the original snapshot"); + set_scripted(&ns, runner(&ns, &prompts), false, 9191, &path).unwrap(); + assert_eq!(ns.proxies()[0], before[0]); + } + + #[test] + fn a_disabled_service_pointing_at_us_is_turned_off() { + 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.set_enabled("Ethernet", false); + set_scripted(&ns, runner(&ns, &prompts), false, 9090, &path).unwrap(); + let ethernet = &ns.proxies()[1]; + assert!(!ethernet.points_at(9090)); + assert_eq!(ethernet.http.server, HOST); } #[test] diff --git a/crates-tauri/yaak-app-proxy/.gitignore b/crates-tauri/yaak-app-proxy/.gitignore index 8cc967540..69bebf61b 100644 --- a/crates-tauri/yaak-app-proxy/.gitignore +++ b/crates-tauri/yaak-app-proxy/.gitignore @@ -2,6 +2,8 @@ # will have compiled files and executables target/ +vendored/* + gen/* **/permissions/autogenerated diff --git a/crates-tauri/yaak-app-proxy/macos/app.yaak.proxy.helper.plist b/crates-tauri/yaak-app-proxy/macos/app.yaak.proxy.helper.plist new file mode 100644 index 000000000..4f418df9b --- /dev/null +++ b/crates-tauri/yaak-app-proxy/macos/app.yaak.proxy.helper.plist @@ -0,0 +1,27 @@ + + + + + + Label + app.yaak.proxy.helper + BundleProgram + Contents/MacOS/yaak-proxy-helper + AssociatedBundleIdentifiers + + app.yaak.proxy + app.yaak.proxy.dev + + RunAtLoad + + + KeepAlive + + SuccessfulExit + + + StandardErrorPath + /Library/Logs/app.yaak.proxy.helper.log + + diff --git a/crates-tauri/yaak-app-proxy/macos/entitlements.plist b/crates-tauri/yaak-app-proxy/macos/entitlements.plist new file mode 100644 index 000000000..c59b3eb79 --- /dev/null +++ b/crates-tauri/yaak-app-proxy/macos/entitlements.plist @@ -0,0 +1,8 @@ + + + + + + + diff --git a/crates-tauri/yaak-app-proxy/src/main.rs b/crates-tauri/yaak-app-proxy/src/main.rs index 70fd252a4..a51f608be 100644 --- a/crates-tauri/yaak-app-proxy/src/main.rs +++ b/crates-tauri/yaak-app-proxy/src/main.rs @@ -1,5 +1,10 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + // Says what macOS reports about the privileged helper, without opening a window. + if std::env::args().any(|a| a == "--helper-status") { + println!("{}", yaak_proxy_lib::helper::diagnose()); + return; + } tauri_app_proxy_lib::run(); } diff --git a/crates-tauri/yaak-app-proxy/tauri.bundle.conf.json b/crates-tauri/yaak-app-proxy/tauri.bundle.conf.json new file mode 100644 index 000000000..b5feca0f4 --- /dev/null +++ b/crates-tauri/yaak-app-proxy/tauri.bundle.conf.json @@ -0,0 +1,16 @@ +{ + "build": { + "beforeBuildCommand": "npm --prefix ../.. run proxy:tauri-before-bundle" + }, + "bundle": { + "externalBin": ["vendored/helper/yaak-proxy-helper"], + "macOS": { + "minimumSystemVersion": "13.0", + "hardenedRuntime": true, + "entitlements": "macos/entitlements.plist", + "files": { + "Library/LaunchDaemons/app.yaak.proxy.helper.plist": "macos/app.yaak.proxy.helper.plist" + } + } + } +} diff --git a/crates-tauri/yaak-proxy-helper/Cargo.toml b/crates-tauri/yaak-proxy-helper/Cargo.toml new file mode 100644 index 000000000..1c585d063 --- /dev/null +++ b/crates-tauri/yaak-proxy-helper/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "yaak-proxy-helper" +version = "0.0.0" +edition = "2024" +authors = ["Gregory Schier"] +publish = false + +# The library is the protocol and the `networksetup` logic, shared with `yaak-proxy-lib`, and +# builds everywhere. The daemon itself is macOS only. +[lib] +name = "yaak_proxy_helper" + +[[bin]] +name = "yaak-proxy-helper" +path = "src/main.rs" + +[features] +# TypeScript bindings for the types the proxy app shows. +ts = ["dep:ts-rs"] +# An in-memory `networksetup`, for tests that change the system proxy. +test-support = [] + +[dependencies] +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +ts-rs = { workspace = true, optional = true } + +[target.'cfg(target_os = "macos")'.dependencies] +core-foundation = "0.10" +libc = "0.2" +security-framework = "3" + +[dev-dependencies] +tempfile = "3" diff --git a/crates-tauri/yaak-proxy-helper/src/auth.rs b/crates-tauri/yaak-proxy-helper/src/auth.rs new file mode 100644 index 000000000..afef2d43b --- /dev/null +++ b/crates-tauri/yaak-proxy-helper/src/auth.rs @@ -0,0 +1,121 @@ +//! Who is on the other end of the socket. The kernel says which process connected, by its +//! audit token, and Security.framework says whether that process is the proxy app signed by +//! [`crate::TEAM_ID`]. The token, unlike a pid, cannot be recycled for another process +//! between the two. + +use core_foundation::base::TCFType; +use core_foundation::data::CFData; +use security_framework::os::macos::code_signing::{ + Flags, GuestAttributes, SecCode, SecRequirement, +}; +use std::os::fd::AsRawFd; +use std::os::unix::net::UnixStream; +use std::str::FromStr; + +/// The process that connected. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Peer { + pub pid: i32, + /// Its `audit_token_t`. + pub audit_token: [u8; 32], +} + +impl Peer { + pub fn of(stream: &UnixStream) -> Result { + let fd = stream.as_raw_fd(); + let mut pid: libc::pid_t = 0; + let mut len = std::mem::size_of::() as libc::socklen_t; + // SAFETY: `pid` and `len` describe a buffer the size LOCAL_PEERPID writes. + let rc = unsafe { + libc::getsockopt( + fd, + libc::SOL_LOCAL, + libc::LOCAL_PEERPID, + (&mut pid as *mut libc::pid_t).cast(), + &mut len, + ) + }; + if rc != 0 { + return Err(format!( + "Failed to read the peer pid: {}", + std::io::Error::last_os_error() + )); + } + let mut audit_token = [0u8; 32]; + let mut len = audit_token.len() as libc::socklen_t; + // SAFETY: as above, for the 32-byte audit_token_t LOCAL_PEERTOKEN writes. + let rc = unsafe { + libc::getsockopt( + fd, + libc::SOL_LOCAL, + libc::LOCAL_PEERTOKEN, + audit_token.as_mut_ptr().cast(), + &mut len, + ) + }; + if rc != 0 || len as usize != audit_token.len() { + return Err(format!( + "Failed to read the peer's audit token: {}", + std::io::Error::last_os_error() + )); + } + Ok(Self { pid, audit_token }) + } +} + +/// Decides whether a peer may use the daemon. +pub trait Verifier: Send + Sync { + fn verify(&self, peer: &Peer) -> Result<(), String>; +} + +/// The real check: the peer's code signature against [`crate::code_requirement`]. +pub struct CodeSignature { + requirement: String, +} + +impl Default for CodeSignature { + fn default() -> Self { + Self { requirement: crate::code_requirement() } + } +} + +impl Verifier for CodeSignature { + fn verify(&self, peer: &Peer) -> Result<(), String> { + let refused = |why: String| format!("Refused pid {}: {why}", peer.pid); + let token = CFData::from_buffer(&peer.audit_token); + let mut attributes = GuestAttributes::new(); + attributes.set_audit_token(token.as_concrete_TypeRef()); + let code = SecCode::copy_guest_with_attribues(None, &attributes, Flags::NONE) + .map_err(|e| refused(format!("no code object ({e})")))?; + let requirement = SecRequirement::from_str(&self.requirement) + .map_err(|e| format!("Invalid code requirement: {e}"))?; + code.check_validity(Flags::NONE, &requirement) + .map_err(|e| refused(format!("not Yaak Proxy signed by {} ({e})", crate::TEAM_ID))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_the_peer_and_refuses_code_not_signed_as_the_app() { + // This test binary is the peer: at most ad-hoc signed, never the proxy app. + let (a, _b) = UnixStream::pair().unwrap(); + let peer = Peer::of(&a).unwrap(); + assert_eq!(peer.pid, std::process::id() as i32); + assert_ne!(peer.audit_token, [0; 32]); + let err = CodeSignature::default().verify(&peer).unwrap_err(); + assert!(err.starts_with(&format!("Refused pid {}", peer.pid)), "{err}"); + } + + #[test] + fn a_requirement_naming_another_team_refuses_too() { + let (a, _b) = UnixStream::pair().unwrap(); + let peer = Peer::of(&a).unwrap(); + let verifier = CodeSignature { + requirement: "anchor apple generic and identifier \"app.yaak.proxy\" and certificate leaf[subject.OU] = \"AAAAAAAAAA\"".into(), + }; + assert!(verifier.verify(&peer).is_err()); + } +} diff --git a/crates-tauri/yaak-proxy-helper/src/daemon.rs b/crates-tauri/yaak-proxy-helper/src/daemon.rs new file mode 100644 index 000000000..4d703c354 --- /dev/null +++ b/crates-tauri/yaak-proxy-helper/src/daemon.rs @@ -0,0 +1,783 @@ +//! The daemon: serves the socket, keeps the saved settings in its state file, and restores +//! them when the process that turned the system proxy on exits, crashes included. + +use crate::auth::{CodeSignature, Peer, Verifier}; +use crate::networksetup::{ + Networksetup, Snapshot, SystemNetworksetup, apply, disable_commands, enable_commands, + read_services, +}; +use crate::process::{Owner, Processes, SystemProcesses}; +use crate::protocol::{MAX_LINE, Request, Response, Status, parse_request}; +use serde::{Deserialize, Serialize}; +use std::io::{BufRead, BufReader, Read, Write}; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::Duration; + +/// How often the owner is checked on. +const WATCH_INTERVAL: Duration = Duration::from_secs(1); + +/// Long enough for `networksetup` on every service, short enough that a silent caller lets go. +const IO_TIMEOUT: Duration = Duration::from_secs(30); + +/// What the daemon has done, saved to its state file before each change, so a daemon that +/// crashes or restarts still knows what to put back. +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct State { + pub active: Option, + /// Saved settings a restore found no service for, a service removed or unplugged, kept + /// for when it is back and merged into the next capture. + #[serde(default)] + pub parked: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Active { + pub owner: Owner, + pub snapshot: Snapshot, +} + +pub struct Daemon { + ns: N, + processes: P, + state_path: PathBuf, + state: Mutex, +} + +impl Daemon { + /// Starts from the state file at `state_path`, or from nothing. + pub fn open(ns: N, processes: P, state_path: PathBuf) -> Self { + let state = match std::fs::read(&state_path) { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| { + log(&format!("Ignoring an unreadable state file: {e}")); + State::default() + }), + Err(_) => State::default(), + }; + Self { ns, processes, state_path, state: Mutex::new(state) } + } + + pub fn state(&self) -> State { + self.lock().clone() + } + + pub fn handle(&self, request: &Request) -> Response { + let result = match *request { + Request::Get => Ok(status(&self.lock())), + Request::Set { enabled: true, port, owner_pid } => self.enable(port, owner_pid), + Request::Set { enabled: false, port, .. } => self.restore(Some(port)), + Request::Restore => self.restore(None), + Request::Unregister => self.unregister(), + }; + match result { + Ok(status) => Response::ok(status), + Err(e) => Response::error(e), + } + } + + /// Points every enabled service at `port`, saving what it replaces first, unless an + /// earlier enable already did and nothing was restored since. + fn enable(&self, port: u16, owner_pid: i32) -> Result { + let owner = self + .processes + .owner(owner_pid) + .ok_or_else(|| format!("The owner, pid {owner_pid}, is not running"))?; + let mut state = self.lock(); + let services = read_services(&self.ns, false)?; + let mut snapshot = match state.active.take() { + Some(active) => active.snapshot, + None => state.parked.take().unwrap_or_default(), + }; + snapshot.extend(&services, port); + state.active = Some(Active { owner, snapshot }); + // Saved before anything changes, so whatever happens next can be undone. + self.save(&state)?; + if let Err(e) = apply(&self.ns, &enable_commands(&services, port)) { + // Half applied is worse than not at all. + if let Err(undo) = self.restore_locked(&mut state, None) { + log(&format!("Failed to undo a partial change: {undo}")); + } + return Err(e); + } + Ok(status(&state)) + } + + /// Turns the system proxy off at `port`, or wherever it points without one: at the saved + /// port this puts back everything saved; at another, a stale one, it only turns off what + /// points there, and the saved settings stay with the routing still live. + fn restore(&self, port: Option) -> Result { + let mut state = self.lock(); + self.restore_locked(&mut state, port)?; + Ok(status(&state)) + } + + fn restore_locked(&self, state: &mut State, port: Option) -> Result<(), String> { + let port = port.unwrap_or(0); + let snapshot = state.active.as_ref().map(|a| &a.snapshot); + if snapshot.is_none() && port == 0 { + return Ok(()); + } + let services = read_services(&self.ns, true)?; + let (commands, used_up) = disable_commands(&services, snapshot, port); + // Saved first, so a restore cut short is finished by the next one. + if let Some(active) = state.active.as_mut() { + active.snapshot.mark_restoring(&commands, &services); + self.save(state)?; + } + // Kept on failure, so the next try still knows what to put back. + apply(&self.ns, &commands)?; + if used_up { + if let Some(active) = state.active.take() + && let Some(leftover) = active.snapshot.leftover(&services) + { + let names: Vec<&str> = leftover.services.iter().map(|s| s.name.as_str()).collect(); + log(&format!("Keeping saved settings no service matched: {}", names.join(", "))); + state.parked = Some(leftover); + } + } else if let Some(active) = state.active.as_mut() { + active.snapshot.restoring.clear(); + } + self.save(state) + } + + /// Restores, then deletes the state file, as the app is about to remove the daemon. Saved + /// settings no service matched stay in it, for a daemon registered again. + fn unregister(&self) -> Result { + let mut state = self.lock(); + self.restore_locked(&mut state, None)?; + if state.parked.is_some() { + return Ok(status(&state)); + } + match std::fs::remove_file(&self.state_path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("Failed to delete the state file: {e}")), + } + Ok(status(&state)) + } + + /// One look at the owner: restores once it has exited. True when it restored. + pub fn watch(&self) -> bool { + let mut state = self.lock(); + let Some(active) = &state.active else { + return false; + }; + if self.processes.is_alive(&active.owner) { + return false; + } + log(&format!("The owner, pid {}, exited; restoring the system proxy", active.owner.pid)); + match self.restore_locked(&mut state, None) { + Ok(()) => true, + Err(e) => { + log(&format!("Failed to restore the system proxy: {e}")); + false + } + } + } + + fn save(&self, state: &State) -> Result<(), String> { + let json = serde_json::to_vec_pretty(state).map_err(|e| e.to_string())?; + let tmp = self.state_path.with_extension("tmp"); + std::fs::write(&tmp, json) + .and_then(|()| std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))) + .and_then(|()| std::fs::rename(&tmp, &self.state_path)) + .map_err(|e| format!("Failed to save the helper's state: {e}")) + } + + fn lock(&self) -> MutexGuard<'_, State> { + // A panic mid-change leaves the state as saved, which is still the truth to restore. + self.state.lock().unwrap_or_else(|e| e.into_inner()) + } +} + +fn status(state: &State) -> Status { + Status { + active: state.active.is_some(), + port: state.active.as_ref().map(|a| a.snapshot.port), + owner_pid: state.active.as_ref().map(|a| a.owner.pid), + version: env!("CARGO_PKG_VERSION").to_string(), + } +} + +/// How many connections are served at once. The app needs one; more are refused. +pub const MAX_CONNECTIONS: usize = 4; + +/// A counting semaphore for connections being served. +pub struct Slots { + taken: Arc, + max: usize, +} + +/// A connection's place among [`Slots`], given back when dropped. +pub struct Slot(Arc); + +impl Drop for Slot { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::SeqCst); + } +} + +impl Slots { + pub fn new(max: usize) -> Self { + Self { taken: Arc::new(AtomicUsize::new(0)), max } + } + + /// A slot, unless all are taken. + pub fn try_take(&self) -> Option { + let max = self.max; + self.taken + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |n| (n < max).then_some(n + 1)) + .ok() + .map(|_| Slot(self.taken.clone())) + } +} + +/// Takes a connection on, before any thread is spawned for it: a free slot, and the kernel's +/// word on who is calling. Both are cheap; the signature check is not, so it runs in the +/// connection's own thread. +pub fn admit(stream: &UnixStream, slots: &Slots) -> Result<(Peer, Slot), String> { + let slot = slots.try_take().ok_or("The helper is busy; try again")?; + let peer = Peer::of(stream)?; + Ok((peer, slot)) +} + +/// Answers a connection that was not admitted with why, briefly, before it is closed. +fn refuse(stream: &UnixStream, why: &str) { + let _ = stream.set_write_timeout(Some(Duration::from_secs(1))); + let mut line = serde_json::to_string(&Response::error(why)).unwrap_or_default(); + line.push('\n'); + let mut writer = stream; + let _ = writer.write_all(line.as_bytes()); +} + +/// Answers one connection's requests, one line each, once the peer passes `verifier`. An +/// unregister leaves the daemon running: it stops when macOS confirms the unregistration, and +/// if that never comes, it is still there to answer. +pub fn serve_connection( + stream: UnixStream, + peer: Peer, + daemon: &Daemon, + verifier: &dyn Verifier, +) { + let _ = stream.set_read_timeout(Some(IO_TIMEOUT)); + let _ = stream.set_write_timeout(Some(IO_TIMEOUT)); + let mut writer = &stream; + let mut reply = |response: &Response| { + let mut line = serde_json::to_string(response).unwrap_or_default(); + line.push('\n'); + writer.write_all(line.as_bytes()).is_ok() + }; + + if let Err(e) = verifier.verify(&peer) { + log(&e); + reply(&Response::error(e)); + return; + } + + let mut reader = BufReader::new(&stream); + loop { + let mut line = String::new(); + match (&mut reader).take(MAX_LINE as u64).read_line(&mut line) { + Ok(0) => return, + Ok(_) if !line.ends_with('\n') => { + reply(&Response::error("Request too long or unterminated")); + return; + } + Ok(_) => {} + Err(_) => return, + } + let response = match parse_request(&line) { + Ok(request) => { + log(&format!("pid {}: {request:?}", peer.pid)); + daemon.handle(&request) + } + Err(e) => Response::error(e), + }; + if !reply(&response) { + return; + } + } +} + +/// The daemon's entry point, run by launchd as root. Returns the exit code. +pub fn run() -> i32 { + // SAFETY: geteuid has no preconditions. + if unsafe { libc::geteuid() } != 0 { + eprintln!("yaak-proxy-helper is started by launchd, as root"); + return 1; + } + // Blocked before any thread starts, so every thread inherits it and only `sigwait` sees + // them. + let signals = blocked_signals(); + + let state_dir = Path::new(crate::STATE_DIR); + if let Err(e) = std::fs::create_dir_all(state_dir) + .and_then(|()| std::fs::set_permissions(state_dir, std::fs::Permissions::from_mode(0o700))) + { + log(&format!("Failed to create {}: {e}", state_dir.display())); + return 1; + } + let daemon = + Arc::new(Daemon::open(SystemNetworksetup, SystemProcesses, state_dir.join("state.json"))); + // The owner may have exited while the daemon was down. + daemon.watch(); + + let _ = std::fs::remove_file(crate::SOCKET_PATH); + let listener = match UnixListener::bind(crate::SOCKET_PATH) { + Ok(listener) => listener, + Err(e) => { + log(&format!("Failed to listen on {}: {e}", crate::SOCKET_PATH)); + return 1; + } + }; + // Anyone may connect; only the signed app is answered. + let _ = std::fs::set_permissions(crate::SOCKET_PATH, std::fs::Permissions::from_mode(0o666)); + log(&format!("Listening on {}", crate::SOCKET_PATH)); + + let watched = daemon.clone(); + std::thread::spawn(move || { + loop { + std::thread::sleep(WATCH_INTERVAL); + watched.watch(); + } + }); + + let stopping = daemon.clone(); + std::thread::spawn(move || { + let mut signal = 0; + // SAFETY: `signals` is an initialised set, and `signal` a valid out pointer. + unsafe { libc::sigwait(&signals, &mut signal) }; + // Stopped by launchd: unregistered in System Settings, or the Mac shutting down. + log(&format!("Signal {signal}; restoring the system proxy and exiting")); + if let Response { ok: false, error: Some(e), .. } = stopping.handle(&Request::Restore) { + log(&format!("Failed to restore the system proxy: {e}")); + } + exit(); + }); + + let verifier: Arc = Arc::new(CodeSignature::default()); + let slots = Slots::new(MAX_CONNECTIONS); + for stream in listener.incoming() { + let Ok(stream) = stream else { + continue; + }; + let (peer, slot) = match admit(&stream, &slots) { + Ok(admitted) => admitted, + Err(why) => { + log(&why); + refuse(&stream, &why); + continue; + } + }; + let daemon = daemon.clone(); + let verifier = verifier.clone(); + let spawned = std::thread::Builder::new().spawn(move || { + let _slot = slot; + serve_connection(stream, peer, &daemon, verifier.as_ref()); + }); + // The closure, and with it the connection and its slot, is dropped: closed and freed. + if let Err(e) = spawned { + log(&format!("Failed to start a thread for pid {}: {e}", peer.pid)); + } + } + 0 +} + +/// Exits cleanly, which launchd does not restart. +fn exit() -> ! { + let _ = std::fs::remove_file(crate::SOCKET_PATH); + std::process::exit(0); +} + +fn blocked_signals() -> libc::sigset_t { + // SAFETY: the set is initialised by sigemptyset before use, and only signal numbers are + // added to it. + unsafe { + let mut set = std::mem::zeroed::(); + libc::sigemptyset(&mut set); + for signal in [libc::SIGTERM, libc::SIGINT, libc::SIGHUP] { + libc::sigaddset(&mut set, signal); + } + libc::pthread_sigmask(libc::SIG_BLOCK, &set, std::ptr::null_mut()); + set + } +} + +fn log(message: &str) { + eprintln!( + "{} {message}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or_default() + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::networksetup::fake::FakeNetworksetup; + use crate::networksetup::{HOST, ProxySetting}; + use std::collections::HashMap; + + /// Processes that are alive while listed. + #[derive(Default)] + struct FakeProcesses(Mutex>); + + impl FakeProcesses { + fn start(&self, pid: i32, started: u64) { + self.0.lock().unwrap().insert(pid, started); + } + fn kill(&self, pid: i32) { + self.0.lock().unwrap().remove(&pid); + } + } + + impl Processes for FakeProcesses { + fn start_time(&self, pid: i32) -> Option { + self.0.lock().unwrap().get(&pid).copied() + } + } + + struct Allow; + impl Verifier for Allow { + fn verify(&self, _peer: &Peer) -> Result<(), String> { + Ok(()) + } + } + + struct Refuse; + impl Verifier for Refuse { + fn verify(&self, peer: &Peer) -> Result<(), String> { + Err(format!("Refused pid {}: not Yaak Proxy", peer.pid)) + } + } + + fn setting(enabled: bool, server: &str, port: u16) -> ProxySetting { + ProxySetting { enabled, server: server.into(), port } + } + + fn daemon(dir: &Path) -> Daemon { + let ns = FakeNetworksetup::new(vec![ + ("Wi-Fi", setting(true, "corp-proxy", 3128), setting(false, "corp-proxy", 3129)), + ("Ethernet", ProxySetting::default(), ProxySetting::default()), + ]); + let processes = FakeProcesses::default(); + processes.start(42, 1000); + Daemon::open(ns, processes, dir.join("state.json")) + } + + fn set(enabled: bool, port: u16) -> Request { + Request::Set { enabled, port, owner_pid: 42 } + } + + fn all_point_at(daemon: &Daemon, port: u16) -> bool { + daemon.ns.proxies().iter().all(|s| s.http.points_at(port) && s.https.points_at(port)) + } + + #[test] + fn set_then_off_restores_exactly() { + let dir = tempfile::tempdir().unwrap(); + let daemon = daemon(dir.path()); + let before = daemon.ns.proxies(); + + let status = daemon.handle(&set(true, 9090)).into_result().unwrap(); + assert!(status.active && status.port == Some(9090) && status.owner_pid == Some(42)); + assert!(all_point_at(&daemon, 9090)); + + let status = daemon.handle(&set(false, 9090)).into_result().unwrap(); + assert!(!status.active); + assert_eq!(daemon.ns.proxies()[0], before[0]); + assert!(!daemon.ns.proxies()[1].points_at(9090)); + } + + #[test] + fn the_snapshot_survives_a_daemon_restart() { + let dir = tempfile::tempdir().unwrap(); + let first = daemon(dir.path()); + let before = first.ns.proxies(); + first.handle(&set(true, 9090)).into_result().unwrap(); + let saved = first.state(); + assert!(saved.active.is_some()); + + // A new daemon over the same machine reads what the first saved. + let second = Daemon::open( + FakeNetworksetup::default(), + FakeProcesses::default(), + dir.path().join("state.json"), + ); + assert_eq!(second.state(), saved); + *second.ns.services.lock().unwrap() = first.ns.services.lock().unwrap().clone(); + second.handle(&Request::Restore).into_result().unwrap(); + assert_eq!(second.ns.proxies()[0], before[0]); + assert_eq!(second.state(), State::default()); + } + + #[test] + fn enabling_twice_keeps_the_first_snapshot() { + let dir = tempfile::tempdir().unwrap(); + let daemon = daemon(dir.path()); + let before = daemon.ns.proxies(); + daemon.handle(&set(true, 9090)).into_result().unwrap(); + // The engine moved ports; what was saved is still the user's. + daemon.handle(&set(true, 9191)).into_result().unwrap(); + assert!(all_point_at(&daemon, 9191)); + daemon.handle(&Request::Restore).into_result().unwrap(); + assert_eq!(daemon.ns.proxies()[0], before[0]); + } + + #[test] + fn a_failed_port_move_still_restores_the_old_port() { + let dir = tempfile::tempdir().unwrap(); + let daemon = daemon(dir.path()); + let before = daemon.ns.proxies(); + daemon.handle(&set(true, 9090)).into_result().unwrap(); + // Moving to 9191 fails on Ethernet, after Wi-Fi moved. + *daemon.ns.failing.lock().unwrap() = Some("Ethernet".into()); + assert!(!daemon.handle(&set(true, 9191)).ok); + // The undo failed on Ethernet too, so what to put back is kept for the next try. + assert!(daemon.state().active.is_some()); + *daemon.ns.failing.lock().unwrap() = None; + daemon.handle(&Request::Restore).into_result().unwrap(); + // Restored everywhere, the old port included. + assert_eq!(daemon.ns.proxies()[0], before[0]); + assert!(!daemon.ns.proxies()[1].points_at(9090)); + assert_eq!(daemon.state(), State::default()); + } + + #[test] + fn turning_off_a_stale_port_keeps_the_live_one() { + let dir = tempfile::tempdir().unwrap(); + let daemon = daemon(dir.path()); + let before = daemon.ns.proxies(); + daemon.handle(&set(true, 9090)).into_result().unwrap(); + daemon.ns.set_enabled("Ethernet", false); + daemon.handle(&set(true, 9191)).into_result().unwrap(); + + // The app's launch cleanup of its old port. + let status = daemon.handle(&set(false, 9090)).into_result().unwrap(); + assert!(status.active && status.port == Some(9191)); + assert!(daemon.ns.proxies()[0].http.points_at(9191)); + assert!(!daemon.ns.proxies()[1].points_at(9090)); + + daemon.handle(&set(false, 9191)).into_result().unwrap(); + assert_eq!(daemon.ns.proxies()[0], before[0]); + } + + #[test] + fn a_service_renamed_while_routing_is_restored() { + let dir = tempfile::tempdir().unwrap(); + let daemon = daemon(dir.path()); + let before = daemon.ns.proxies(); + daemon.handle(&set(true, 9090)).into_result().unwrap(); + daemon.ns.rename("Wi-Fi", "Office Wi-Fi"); + daemon.handle(&set(false, 9090)).into_result().unwrap(); + let after = &daemon.ns.proxies()[0]; + assert_eq!((&after.http, &after.https), (&before[0].http, &before[0].https)); + assert_eq!(daemon.state(), State::default()); + } + + #[test] + fn settings_no_service_matched_are_parked_and_merged_back() { + let dir = tempfile::tempdir().unwrap(); + let daemon = daemon(dir.path()); + let wifi = daemon.ns.proxies()[0].clone(); + daemon.handle(&set(true, 9090)).into_result().unwrap(); + // Wi-Fi is unplugged while ours is on. + daemon.ns.remove("Wi-Fi"); + daemon.handle(&set(false, 9090)).into_result().unwrap(); + let parked = daemon.state().parked.unwrap(); + assert_eq!(parked.services.len(), 1); + assert_eq!(parked.services[0].http, wifi.http); + + // Kept through unregister, and back in the next capture. + daemon.handle(&Request::Unregister).into_result().unwrap(); + assert!(dir.path().join("state.json").exists()); + daemon.handle(&set(true, 9090)).into_result().unwrap(); + let state = daemon.state(); + assert!(state.parked.is_none()); + assert!(state.active.unwrap().snapshot.services.iter().any(|s| s.http == wifi.http)); + } + + #[test] + fn the_watchdog_restores_once_the_owner_exits() { + let dir = tempfile::tempdir().unwrap(); + let daemon = daemon(dir.path()); + let before = daemon.ns.proxies(); + daemon.handle(&set(true, 9090)).into_result().unwrap(); + + assert!(!daemon.watch(), "the owner is alive"); + assert!(all_point_at(&daemon, 9090)); + + daemon.processes.kill(42); + assert!(daemon.watch()); + assert_eq!(daemon.ns.proxies()[0], before[0]); + assert!(!daemon.watch(), "nothing left to restore"); + } + + #[test] + fn the_watchdog_treats_a_reused_pid_as_gone() { + let dir = tempfile::tempdir().unwrap(); + let daemon = daemon(dir.path()); + daemon.handle(&set(true, 9090)).into_result().unwrap(); + // The owner exited and another process got its pid. + daemon.processes.start(42, 2000); + assert!(daemon.watch()); + assert!(!daemon.ns.proxies()[1].points_at(9090)); + } + + #[test] + fn refuses_an_owner_that_is_not_running() { + let dir = tempfile::tempdir().unwrap(); + let daemon = daemon(dir.path()); + let err = daemon.handle(&Request::Set { enabled: true, port: 9090, owner_pid: 7 }); + assert!(!err.ok); + assert!(daemon.ns.changes.lock().unwrap().is_empty()); + } + + #[test] + fn a_failed_enable_undoes_what_it_did() { + let dir = tempfile::tempdir().unwrap(); + let daemon = daemon(dir.path()); + let before = daemon.ns.proxies(); + // Ethernet goes away mid-change, after Wi-Fi was pointed at us. + *daemon.ns.failing.lock().unwrap() = Some("Ethernet".into()); + assert!(!daemon.handle(&set(true, 9090)).ok); + assert_eq!(daemon.ns.proxies(), before); + assert_eq!(daemon.state(), State::default()); + } + + #[test] + fn unregister_restores_and_deletes_the_state_file() { + let dir = tempfile::tempdir().unwrap(); + let daemon = daemon(dir.path()); + daemon.handle(&set(true, 9090)).into_result().unwrap(); + assert!(dir.path().join("state.json").exists()); + daemon.handle(&Request::Unregister).into_result().unwrap(); + assert!(!dir.path().join("state.json").exists()); + assert!(!daemon.ns.proxies()[0].points_at(9090)); + } + + #[test] + fn get_reports_without_changing_anything() { + let dir = tempfile::tempdir().unwrap(); + let daemon = daemon(dir.path()); + let status = daemon.handle(&Request::Get).into_result().unwrap(); + assert!(!status.active); + assert!(daemon.ns.changes.lock().unwrap().is_empty()); + } + + fn exchange(verifier: &dyn Verifier, lines: &[&str]) -> (Vec, Vec) { + let dir = tempfile::tempdir().unwrap(); + let daemon = daemon(dir.path()); + let (server, mut client) = UnixStream::pair().unwrap(); + for line in lines { + client.write_all(format!("{line}\n").as_bytes()).unwrap(); + } + client.shutdown(std::net::Shutdown::Write).unwrap(); + let peer = Peer::of(&server).unwrap(); + serve_connection(server, peer, &daemon, verifier); + let mut out = String::new(); + client.read_to_string(&mut out).unwrap(); + let responses = out.lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + let http = daemon.ns.proxies().into_iter().map(|s| s.http).collect(); + (responses, http) + } + + #[test] + fn serves_a_verified_peer_line_by_line() { + let pid = std::process::id() as i32; + let (responses, http) = exchange( + &AllowWithOwner(pid), + &[ + r#"{"cmd":"get"}"#, + r#"{"cmd":"set","enabled":true,"port":80,"owner_pid":42}"#, + r#"{"cmd":"set","enabled":true,"port":9090,"owner_pid":42}"#, + ], + ); + assert_eq!(responses.len(), 3); + assert!(responses[0].ok && !responses[1].ok && responses[2].ok); + assert!(http.iter().all(|s| s.server == HOST && s.port == 9090)); + } + + #[test] + fn refuses_an_unverified_peer_before_reading_anything() { + let (responses, http) = + exchange(&Refuse, &[r#"{"cmd":"set","enabled":true,"port":9090,"owner_pid":42}"#]); + assert_eq!(responses.len(), 1); + assert!(responses[0].error.as_deref().unwrap().starts_with("Refused pid")); + assert!(http.iter().all(|s| s.server != HOST)); + } + + #[test] + fn refuses_a_peer_with_the_real_check() { + // The test binary is not the signed app. + let (responses, http) = exchange(&CodeSignature::default(), &[r#"{"cmd":"restore"}"#]); + assert!(!responses[0].ok); + assert!(http.iter().all(|s| s.server != HOST)); + } + + #[test] + fn keeps_answering_after_unregister() { + // Should macOS fail to unregister it, the app can still reach it. + let (responses, _) = exchange( + &Allow, + &[ + r#"{"cmd":"set","enabled":true,"port":9090,"owner_pid":42}"#, + r#"{"cmd":"unregister"}"#, + r#"{"cmd":"set","enabled":true,"port":9090,"owner_pid":42}"#, + ], + ); + assert_eq!(responses.len(), 3); + assert!(responses.iter().all(|r| r.ok)); + } + + #[test] + fn slots_cap_connections_and_free_on_drop() { + let slots = Slots::new(2); + let a = slots.try_take().unwrap(); + let _b = slots.try_take().unwrap(); + assert!(slots.try_take().is_none()); + drop(a); + assert!(slots.try_take().is_some()); + } + + #[test] + fn refuses_a_connection_over_the_cap_with_a_short_reply() { + let slots = Slots::new(MAX_CONNECTIONS); + let held: Vec<_> = (0..MAX_CONNECTIONS).map(|_| slots.try_take().unwrap()).collect(); + let (server, mut client) = UnixStream::pair().unwrap(); + let why = admit(&server, &slots).err().unwrap(); + refuse(&server, &why); + drop(server); + let mut out = String::new(); + client.read_to_string(&mut out).unwrap(); + let response: Response = serde_json::from_str(out.trim()).unwrap(); + assert_eq!(response.error.as_deref(), Some("The helper is busy; try again")); + + // Admitted again once one is free, with the peer read before any thread runs. + drop(held); + let (server, _client) = UnixStream::pair().unwrap(); + let (peer, _slot) = admit(&server, &slots).unwrap(); + assert_eq!(peer.pid, std::process::id() as i32); + } + + #[test] + fn stops_reading_an_overlong_line() { + let long = format!(r#"{{"cmd":"get","x":"{}"}}"#, "a".repeat(MAX_LINE)); + let (responses, _) = exchange(&Allow, &[&long, r#"{"cmd":"get"}"#]); + assert_eq!(responses.len(), 1); + assert!(!responses[0].ok); + } + + /// Allows only the expected peer, this test process. + struct AllowWithOwner(i32); + impl Verifier for AllowWithOwner { + fn verify(&self, peer: &Peer) -> Result<(), String> { + if peer.pid == self.0 { Ok(()) } else { Err("wrong peer".into()) } + } + } +} diff --git a/crates-tauri/yaak-proxy-helper/src/lib.rs b/crates-tauri/yaak-proxy-helper/src/lib.rs new file mode 100644 index 000000000..28bac30c8 --- /dev/null +++ b/crates-tauri/yaak-proxy-helper/src/lib.rs @@ -0,0 +1,68 @@ +//! Yaak Proxy's privileged helper: a root launchd daemon, registered by the app through +//! `SMAppService`, that points this Mac's web proxies at the engine and puts the user's own +//! settings back afterwards, so toggling the system proxy never asks for a password. +//! +//! The app talks to it over a Unix socket, one JSON line per request ([`protocol`]). Only a +//! caller signed as the proxy app by [`TEAM_ID`] is served. + +pub mod networksetup; +pub mod protocol; + +#[cfg(target_os = "macos")] +pub mod auth; +#[cfg(target_os = "macos")] +pub mod daemon; +#[cfg(target_os = "macos")] +pub mod process; + +/// The launchd label, which is also the plist's name without `.plist`. +pub const LABEL: &str = "app.yaak.proxy.helper"; + +/// The plist inside the app, at `Contents/Library/LaunchDaemons/`. +pub const PLIST_NAME: &str = "app.yaak.proxy.helper.plist"; + +/// Created by the daemon, root-owned and world-writable: who may talk is decided per caller. +pub const SOCKET_PATH: &str = "/var/run/app.yaak.proxy.helper.sock"; + +/// The daemon's own state, root only: the settings to restore, and whose they are. +pub const STATE_DIR: &str = "/var/db/app.yaak.proxy.helper"; + +/// The Apple Developer Team that signs Yaak Proxy, set at build time with +/// `YAAK_PROXY_TEAM_ID`. +pub const TEAM_ID: &str = match option_env!("YAAK_PROXY_TEAM_ID") { + Some(team) => team, + None => "7PU3P6ELJ8", +}; + +/// The bundle identifiers of the apps served: the release app, and the dev app as well in a +/// debug build of the helper. +pub const APP_IDENTIFIERS: &[&str] = if cfg!(debug_assertions) { + &["app.yaak.proxy", "app.yaak.proxy.dev"] +} else { + &["app.yaak.proxy"] +}; + +/// The code requirement a caller must satisfy. +pub fn code_requirement() -> String { + let identifiers = APP_IDENTIFIERS + .iter() + .map(|id| format!("identifier \"{id}\"")) + .collect::>() + .join(" or "); + format!( + "anchor apple generic and ({identifiers}) and certificate leaf[subject.OU] = \"{TEAM_ID}\"" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn requires_the_app_signed_by_the_team() { + let requirement = code_requirement(); + assert!(requirement.starts_with("anchor apple generic and (identifier \"app.yaak.proxy\"")); + assert!(requirement.ends_with(&format!("certificate leaf[subject.OU] = \"{TEAM_ID}\""))); + assert_eq!(TEAM_ID.len(), 10); + } +} diff --git a/crates-tauri/yaak-proxy-helper/src/main.rs b/crates-tauri/yaak-proxy-helper/src/main.rs new file mode 100644 index 000000000..db7ff77cb --- /dev/null +++ b/crates-tauri/yaak-proxy-helper/src/main.rs @@ -0,0 +1,10 @@ +#[cfg(target_os = "macos")] +fn main() { + std::process::exit(yaak_proxy_helper::daemon::run()); +} + +#[cfg(not(target_os = "macos"))] +fn main() { + eprintln!("yaak-proxy-helper only runs on macOS"); + std::process::exit(1); +} diff --git a/crates-tauri/yaak-proxy-helper/src/networksetup.rs b/crates-tauri/yaak-proxy-helper/src/networksetup.rs new file mode 100644 index 000000000..a6571015d --- /dev/null +++ b/crates-tauri/yaak-proxy-helper/src/networksetup.rs @@ -0,0 +1,944 @@ +//! The macOS system proxy as `networksetup` sees it: the web and secure web proxies of each +//! network service. Reading needs no rights; changing needs an administrator, which the +//! daemon is and the app's password-dialog fallback borrows. +//! +//! Before pointing the services at `127.0.0.1:`, their settings are saved in a +//! [`Snapshot`]; turning off puts back exactly what was there, not just "off". + +use serde::{Deserialize, Serialize}; + +pub const NETWORKSETUP: &str = "/usr/sbin/networksetup"; + +/// Where the system proxy is always pointed. Never anywhere else, so a compromised caller can +/// at worst send this Mac's traffic to localhost. +pub const HOST: &str = "127.0.0.1"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export_to = "gen_rpc.ts"))] +#[serde(rename_all = "camelCase")] +pub struct ServiceProxy { + /// The network service, as System Settings names it: "Wi-Fi", "USB 10/100/1000 LAN". + pub name: String, + /// What stays the same when the service is renamed: its device ("en0"), or its hardware + /// port when it has none. Absent when `networksetup` lists neither. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts", ts(optional))] + pub id: Option, + pub http: ProxySetting, + pub https: ProxySetting, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export_to = "gen_rpc.ts"))] +#[serde(rename_all = "camelCase")] +pub struct ProxySetting { + pub enabled: bool, + pub server: String, + pub port: u16, +} + +impl ProxySetting { + pub fn points_at(&self, port: u16) -> bool { + self.enabled && self.server == HOST && self.port == port + } + + /// This setting as the user's own: one pointing at one of `ports` is ours, left behind, so + /// it is saved as off. + fn owned(&self, ports: &[u16]) -> Self { + let ours = ports.iter().any(|p| self.points_at(*p)); + Self { enabled: self.enabled && !ours, ..self.clone() } + } +} + +impl ServiceProxy { + /// A service known by its name only. + pub fn new(name: &str, http: ProxySetting, https: ProxySetting) -> Self { + Self { name: name.into(), id: None, http, https } + } + + /// Either proxy of the service sends traffic to `127.0.0.1:`. + pub fn points_at(&self, port: u16) -> bool { + self.http.points_at(port) || self.https.points_at(port) + } +} + +/// The settings of the services the system proxy changed, as they were before, and the port +/// it points them at. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Snapshot { + pub port: u16, + /// Ports it pointed at before moving to `port`, where a service it could not move, or a + /// disabled one, may still point. + #[serde(default)] + pub previous_ports: Vec, + pub services: Vec, + /// Proxies a restore has started putting the saved server back on, as `(saved service, + /// "http" or "https")`, keyed by the saved entry's name, which a rename doesn't change. + /// Setting a server turns the proxy on, so one of these found on with its saved server, + /// saved as off, is a restore that stopped halfway. + #[serde(default)] + pub restoring: Vec<(String, String)>, +} + +impl Snapshot { + /// Records the restores `commands` start over `services`, saved before running them. + pub fn mark_restoring(&mut self, commands: &[Vec], services: &[ServiceProxy]) { + let mut started = Vec::new(); + for args in commands { + let proto = match args.first().map(String::as_str) { + Some("-setwebproxy") => "http", + Some("-setsecurewebproxy") => "https", + _ => continue, + }; + let Some(name) = args.get(1) else { continue }; + let saved = services + .iter() + .find(|s| &s.name == name) + .and_then(|s| self.get(s, services)) + .map_or_else(|| name.clone(), |saved| saved.name.clone()); + started.push((saved, proto.to_string())); + } + for entry in started { + if !self.restoring.contains(&entry) { + self.restoring.push(entry); + } + } + } + + fn is_restoring(&self, name: &str, proto: &str) -> bool { + self.restoring.iter().any(|(n, p)| n == name && p == proto) + } + + pub fn capture(services: &[ServiceProxy], port: u16) -> Self { + let mut snapshot = Self::default(); + snapshot.extend(services, port); + snapshot + } + + /// Records `services` not saved yet, a network connected since the first capture, and + /// moves the snapshot to `port`. Those already saved keep the user's settings, not ours. + pub fn extend(&mut self, services: &[ServiceProxy], port: u16) { + if self.port != 0 && self.port != port && !self.previous_ports.contains(&self.port) { + self.previous_ports.push(self.port); + } + self.previous_ports.retain(|p| *p != port); + self.port = port; + let ports = self.ports(); + // Judged against the snapshot as it was, so an entry added here can't change how the + // services after it match. + let new: Vec = services + .iter() + .filter(|service| self.get(service, services).is_none()) + .map(|service| ServiceProxy { + name: service.name.clone(), + id: service.id.clone(), + http: service.http.owned(&ports), + https: service.https.owned(&ports), + }) + .collect(); + self.services.extend(new); + } + + /// Every port this snapshot has pointed services at, the current one first. + pub fn ports(&self) -> Vec { + std::iter::once(self.port).chain(self.previous_ports.iter().copied()).collect() + } + + /// What was saved for `service`, one of `services`, the network's services now: the entry + /// with its name, when the ids don't disagree, or, for a service renamed since, the entry + /// on its device whose name no service has any more. Services can share a device, so the + /// device decides only when it is unambiguous both ways: one such saved entry, and one + /// service on the device with no saved entry by name. Otherwise nothing matches, and the + /// saved entry is parked rather than given to the wrong service. + fn get(&self, service: &ServiceProxy, services: &[ServiceProxy]) -> Option<&ServiceProxy> { + let ids_agree = + |s: &ServiceProxy| s.id.is_none() || service.id.is_none() || s.id == service.id; + if let Some(saved) = self.services.iter().find(|s| s.name == service.name && ids_agree(s)) { + return Some(saved); + } + let id = service.id.as_ref()?; + let mut orphans = self + .services + .iter() + .filter(|s| s.id.as_ref() == Some(id) && !services.iter().any(|c| c.name == s.name)); + let saved = orphans.next()?; + let unclaimed = services + .iter() + .filter(|c| { + c.id.as_ref() == Some(id) && !self.services.iter().any(|s| s.name == c.name) + }) + .count(); + (orphans.next().is_none() && unclaimed == 1).then_some(saved) + } + + /// What is left once this snapshot is restored over `services`: the saved entries none of + /// them matched, a service removed or unplugged, kept so its settings are not lost. They + /// are parked at port 0, which routes nothing. + pub fn leftover(&self, services: &[ServiceProxy]) -> Option { + let unmatched: Vec = self + .services + .iter() + .filter(|saved| { + !services + .iter() + .any(|s| self.get(s, services).is_some_and(|m| std::ptr::eq(m, *saved))) + }) + .cloned() + .collect(); + if unmatched.is_empty() { + return None; + } + Some(Snapshot { services: unmatched, ..Snapshot::default() }) + } +} + +/// The calls that turn the system proxy off at `port`, and whether `snapshot` is used up by +/// them. At the snapshot's current port, or at port 0 meaning wherever it is, they put back +/// everything it saved. At any other port they put back only what points there, leaving the +/// snapshot to the routing still live on its own port. +pub fn disable_commands( + services: &[ServiceProxy], + snapshot: Option<&Snapshot>, + port: u16, +) -> (Vec>, bool) { + match snapshot { + Some(snapshot) if port == 0 || port == snapshot.port => { + let mut ports = snapshot.ports(); + if port != 0 && !ports.contains(&port) { + ports.push(port); + } + (restore_commands(services, Some(snapshot), &ports), true) + } + Some(snapshot) => (restore_commands(services, Some(snapshot), &[port]), false), + None if port == 0 => (Vec::new(), true), + None => (restore_commands(services, None, &[port]), true), + } +} + +/// The services from `networksetup -listallnetworkservices`, the disabled ones too with +/// `include_disabled`. The first line is a legend; a leading `*` marks a disabled service. +pub fn parse_services(output: &str, include_disabled: bool) -> Vec { + output + .lines() + .skip(1) + .map(str::trim) + .filter(|l| !l.is_empty() && (include_disabled || !l.starts_with('*'))) + .map(|l| l.trim_start_matches('*').to_string()) + .collect() +} + +/// Each service's id from `networksetup -listnetworkserviceorder`, as `(name, id)`: its device, +/// or `port:` when it has no device, as a VPN may not. +pub fn parse_service_ids(output: &str) -> Vec<(String, String)> { + let mut ids = Vec::new(); + let mut name = None; + for line in output.lines().map(str::trim) { + if let Some(rest) = line.strip_prefix("(Hardware Port:") { + let Some(name) = name.take() else { continue }; + let rest = rest.trim_end_matches(')'); + let (port, device) = rest.split_once(", Device:").unwrap_or((rest, "")); + let (port, device) = (port.trim(), device.trim()); + if !device.is_empty() { + ids.push((name, device.to_string())); + } else if !port.is_empty() { + ids.push((name, format!("port:{port}"))); + } + } else if line.starts_with('(') + && let Some((_, rest)) = line.split_once(") ") + { + // "(1) Wi-Fi", or "(*) Name" for a disabled service. + name = Some(rest.to_string()); + } + } + ids +} + +/// The output of `networksetup -getwebproxy` or `-getsecurewebproxy`. +pub fn parse_proxy(output: &str) -> ProxySetting { + let mut setting = ProxySetting::default(); + for line in output.lines() { + let Some((key, value)) = line.split_once(':') else { + continue; + }; + let value = value.trim(); + match key.trim() { + "Enabled" => setting.enabled = value.eq_ignore_ascii_case("yes"), + "Server" => setting.server = value.to_string(), + "Port" => setting.port = value.parse().unwrap_or_default(), + _ => {} + } + } + setting +} + +const WEB: [(&str, &str); 2] = [ + ("-setwebproxy", "-setwebproxystate"), + ("-setsecurewebproxy", "-setsecurewebproxystate"), +]; + +/// The `networksetup` calls that point every one of `services` at `127.0.0.1:`. +pub fn enable_commands(services: &[ServiceProxy], port: u16) -> Vec> { + let mut commands = Vec::new(); + for service in services { + for (set, state) in WEB { + let name = service.name.clone(); + commands.push(vec![set.into(), name.clone(), HOST.into(), port.to_string()]); + commands.push(vec![state.into(), name, "on".into()]); + } + } + commands +} + +/// The `networksetup` calls that put back what `snapshot` saved for each proxy of `services` +/// still pointing at one of `ports`, and turn off those it has nothing saved for. A proxy the +/// user has pointed elsewhere since is theirs, and left alone. +pub fn restore_commands( + services: &[ServiceProxy], + snapshot: Option<&Snapshot>, + ports: &[u16], +) -> Vec> { + let mut commands = Vec::new(); + for service in services { + let saved_service = snapshot.and_then(|s| s.get(service, services)); + let proxies = [ + (&service.http, saved_service.map(|s| &s.http)), + (&service.https, saved_service.map(|s| &s.https)), + ]; + for (((current, saved), (set, state)), proto) in + proxies.into_iter().zip(WEB).zip(["http", "https"]) + { + let name = service.name.clone(); + // An earlier restore set the user's server, which turned it on, and stopped + // before turning it back off. Marked by the saved entry, so a rename since + // doesn't hide it. + let restoring = saved_service + .zip(snapshot) + .is_some_and(|(saved, snapshot)| snapshot.is_restoring(&saved.name, proto)); + let unfinished = saved.is_some_and(|s| { + !s.enabled + && current.enabled + && current.server == s.server + && current.port == s.port + && restoring + }); + if unfinished { + commands.push(vec![state.into(), name, "off".into()]); + continue; + } + if !ports.iter().any(|p| current.points_at(*p)) { + continue; + } + match saved { + // Setting the server turns the proxy on, so its state follows. + Some(saved) if !saved.server.is_empty() && saved.port != 0 => { + let port = saved.port.to_string(); + commands.push(vec![set.into(), name.clone(), saved.server.clone(), port]); + commands.push(vec![state.into(), name, on_off(saved.enabled)]); + } + _ => commands.push(vec![state.into(), name, "off".into()]), + } + } + } + commands +} + +fn on_off(on: bool) -> String { + if on { "on" } else { "off" }.to_string() +} + +/// Runs `networksetup`, real or stubbed. +pub trait Networksetup { + /// Runs it with `args`, returning what it printed. + fn run(&self, args: &[String]) -> Result; +} + +/// `/usr/sbin/networksetup` itself. +pub struct SystemNetworksetup; + +impl Networksetup for SystemNetworksetup { + fn run(&self, args: &[String]) -> Result { + let output = std::process::Command::new(NETWORKSETUP) + .args(args) + .output() + .map_err(|e| format!("Failed to run {NETWORKSETUP}: {e}"))?; + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(if stderr.is_empty() { + format!("{NETWORKSETUP} exited with {}", output.status) + } else { + stderr + }); + } + // It reports some failures, such as an unknown service, on stdout with status 0. + if stdout.starts_with("** Error") { + return Err(stdout.trim().to_string()); + } + Ok(stdout) + } +} + +/// Every service and its proxies, the disabled ones too with `include_disabled`. +pub fn read_services( + ns: &impl Networksetup, + include_disabled: bool, +) -> Result, String> { + let list = ns.run(&["-listallnetworkservices".to_string()])?; + // Without ids, services are still matched by name. + let ids = ns + .run(&["-listnetworkserviceorder".to_string()]) + .map(|order| parse_service_ids(&order)) + .unwrap_or_default(); + parse_services(&list, include_disabled) + .into_iter() + .map(|name| { + let http = parse_proxy(&ns.run(&["-getwebproxy".into(), name.clone()])?); + let https = parse_proxy(&ns.run(&["-getsecurewebproxy".into(), name.clone()])?); + let id = ids.iter().find(|(n, _)| *n == name).map(|(_, id)| id.clone()); + Ok(ServiceProxy { name, id, http, https }) + }) + .collect() +} + +/// Runs `commands` in order, stopping at the first that fails. +pub fn apply(ns: &impl Networksetup, commands: &[Vec]) -> Result<(), String> { + for args in commands { + ns.run(args)?; + } + Ok(()) +} + +/// A `networksetup` that keeps its services in memory, for tests here and in the app. +#[cfg(any(test, feature = "test-support"))] +pub mod fake { + use super::*; + use std::sync::Mutex; + + #[derive(Debug, Clone, PartialEq, Eq)] + pub struct FakeService { + pub proxy: ServiceProxy, + pub enabled: bool, + } + + #[derive(Default)] + pub struct FakeNetworksetup { + pub services: Mutex>, + /// Every change made, in order. + pub changes: Mutex>>, + /// A service whose changes fail, as one removed mid-change would. + pub failing: Mutex>, + } + + impl FakeNetworksetup { + /// Services on devices `en0`, `en1` and on, in order. + pub fn new(services: Vec<(&str, ProxySetting, ProxySetting)>) -> Self { + let services = services + .into_iter() + .enumerate() + .map(|(i, (name, http, https))| FakeService { + proxy: ServiceProxy { + name: name.into(), + id: Some(format!("en{i}")), + http, + https, + }, + enabled: true, + }) + .collect(); + Self { services: Mutex::new(services), ..Default::default() } + } + + /// Renames a service, as System Settings can; its device stays. + pub fn rename(&self, name: &str, to: &str) { + let mut services = self.services.lock().unwrap(); + services.iter_mut().find(|s| s.proxy.name == name).unwrap().proxy.name = to.into(); + } + + /// Moves a service onto `device`, which another can share, as a duplicated one does. + pub fn set_device(&self, name: &str, device: &str) { + let mut services = self.services.lock().unwrap(); + services.iter_mut().find(|s| s.proxy.name == name).unwrap().proxy.id = + Some(device.into()); + } + + /// Adds an enabled service on `device`. + pub fn add(&self, name: &str, device: &str, http: ProxySetting, https: ProxySetting) { + let mut proxy = ServiceProxy::new(name, http, https); + proxy.id = Some(device.into()); + self.services.lock().unwrap().push(FakeService { proxy, enabled: true }); + } + + /// Removes a service, as unplugging an adapter can. + pub fn remove(&self, name: &str) { + self.services.lock().unwrap().retain(|s| s.proxy.name != name); + } + + /// The services' proxies, disabled ones included. + pub fn proxies(&self) -> Vec { + self.services.lock().unwrap().iter().map(|s| s.proxy.clone()).collect() + } + + pub fn set_enabled(&self, name: &str, enabled: bool) { + let mut services = self.services.lock().unwrap(); + services.iter_mut().find(|s| s.proxy.name == name).unwrap().enabled = enabled; + } + + pub fn set_proxy(&self, name: &str, http: ProxySetting, https: ProxySetting) { + let mut services = self.services.lock().unwrap(); + let service = services.iter_mut().find(|s| s.proxy.name == name).unwrap(); + service.proxy.http = http; + service.proxy.https = https; + } + } + + impl Networksetup for FakeNetworksetup { + fn run(&self, args: &[String]) -> Result { + let mut services = self.services.lock().unwrap(); + if args.first().map(String::as_str) == Some("-listallnetworkservices") { + let mut out = + "An asterisk (*) denotes that a network service is disabled.\n".to_string(); + for s in services.iter() { + out.push_str(&format!( + "{}{}\n", + if s.enabled { "" } else { "*" }, + s.proxy.name + )); + } + return Ok(out); + } + if args.first().map(String::as_str) == Some("-listnetworkserviceorder") { + let mut out = + "An asterisk (*) denotes that a network service is disabled.\n".to_string(); + for (i, s) in services.iter().enumerate() { + let order = if s.enabled { (i + 1).to_string() } else { "*".into() }; + let device = s.proxy.id.clone().unwrap_or_default(); + out.push_str(&format!( + "({order}) {}\n(Hardware Port: {}, Device: {device})\n\n", + s.proxy.name, s.proxy.name + )); + } + return Ok(out); + } + let [command, name, rest @ ..] = args else { + return Err(format!("bad arguments {args:?}")); + }; + let service = services + .iter_mut() + .find(|s| &s.proxy.name == name) + .ok_or_else(|| format!("** Error: {name} is not a recognized network service."))?; + let setting = if command.contains("secure") { + &mut service.proxy.https + } else { + &mut service.proxy.http + }; + match (command.as_str(), rest) { + ("-getwebproxy" | "-getsecurewebproxy", []) => { + return Ok(format!( + "Enabled: {}\nServer: {}\nPort: {}\nAuthenticated Proxy Enabled: 0\n", + if setting.enabled { "Yes" } else { "No" }, + setting.server, + setting.port + )); + } + (_, [_, ..]) if self.failing.lock().unwrap().as_ref() == Some(name) => { + return Err(format!("** Error: {name} is not a recognized network service.")); + } + ("-setwebproxy" | "-setsecurewebproxy", [server, port]) => { + setting.server = server.clone(); + setting.port = port.parse().map_err(|_| format!("bad port {port}"))?; + setting.enabled = true; + } + ("-setwebproxystate" | "-setsecurewebproxystate", [state]) => { + setting.enabled = state == "on"; + } + _ => return Err(format!("bad arguments {args:?}")), + } + self.changes.lock().unwrap().push(args.to_vec()); + Ok(String::new()) + } + } +} + +#[cfg(test)] +mod tests { + use super::fake::FakeNetworksetup; + use super::*; + + fn setting(enabled: bool, server: &str, port: u16) -> ProxySetting { + ProxySetting { enabled, server: server.into(), port } + } + + fn ours(port: u16) -> ProxySetting { + setting(true, HOST, port) + } + + #[test] + fn parses_services() { + let output = "An asterisk (*) denotes that a network service is disabled.\nWi-Fi\n*Thunderbolt Bridge\nUSB 10/100/1000 LAN\n\n"; + assert_eq!(parse_services(output, false), vec!["Wi-Fi", "USB 10/100/1000 LAN"]); + // A disabled service can still hold a proxy setting pointing at us. + assert_eq!( + parse_services(output, true), + vec!["Wi-Fi", "Thunderbolt Bridge", "USB 10/100/1000 LAN"] + ); + } + + #[test] + fn parses_proxy() { + let on = "Enabled: Yes\nServer: 127.0.0.1\nPort: 9090\nAuthenticated Proxy Enabled: 0\n"; + assert_eq!(parse_proxy(on), ours(9090)); + let off = "Enabled: No\nServer: \nPort: 0\nAuthenticated Proxy Enabled: 0\n"; + assert_eq!(parse_proxy(off), setting(false, "", 0)); + } + + #[test] + fn parses_service_ids() { + let output = "An asterisk (*) denotes that a network service is disabled.\n(1) Wi-Fi\n(Hardware Port: Wi-Fi, Device: en0)\n\n(*) Thunderbolt Bridge\n(Hardware Port: Thunderbolt Bridge, Device: bridge0)\n\n(3) Tailscale\n(Hardware Port: io.tailscale.ipn.macsys, Device: )\n\n"; + assert_eq!( + parse_service_ids(output), + vec![ + ("Wi-Fi".to_string(), "en0".to_string()), + ("Thunderbolt Bridge".to_string(), "bridge0".to_string()), + ("Tailscale".to_string(), "port:io.tailscale.ipn.macsys".to_string()), + ] + ); + } + + #[test] + fn a_service_renamed_while_routing_gets_its_settings_back() { + let corp = setting(true, "corp-proxy", 3128); + let ns = FakeNetworksetup::new(vec![("Wi-Fi", corp.clone(), corp.clone())]); + let snapshot = Snapshot::capture(&read_services(&ns, false).unwrap(), 9090); + assert_eq!(snapshot.services[0].id.as_deref(), Some("en0")); + apply(&ns, &enable_commands(&read_services(&ns, false).unwrap(), 9090)).unwrap(); + + ns.rename("Wi-Fi", "Office Wi-Fi"); + let services = read_services(&ns, true).unwrap(); + let (commands, used_up) = disable_commands(&services, Some(&snapshot), 9090); + apply(&ns, &commands).unwrap(); + assert_eq!(ns.proxies()[0].http, corp); + assert_eq!(ns.proxies()[0].https, corp); + assert!(used_up && snapshot.leftover(&services).is_none()); + } + + #[test] + fn services_sharing_a_device_keep_their_own_settings() { + let corp = setting(true, "corp-proxy", 3128); + let lab = setting(true, "lab-proxy", 8888); + let ns = FakeNetworksetup::new(vec![ + ("Wi-Fi", corp.clone(), corp.clone()), + ("Wi-Fi Lab", lab.clone(), lab.clone()), + ]); + ns.set_device("Wi-Fi Lab", "en0"); + let before = ns.proxies(); + let snapshot = Snapshot::capture(&read_services(&ns, false).unwrap(), 9090); + assert_eq!(snapshot.services.len(), 2); + apply(&ns, &enable_commands(&read_services(&ns, false).unwrap(), 9090)).unwrap(); + + let services = read_services(&ns, true).unwrap(); + let (commands, _) = disable_commands(&services, Some(&snapshot), 9090); + apply(&ns, &commands).unwrap(); + assert_eq!(ns.proxies(), before); + assert!(snapshot.leftover(&services).is_none()); + } + + #[test] + fn a_rename_plus_a_new_service_on_its_device_matches_neither() { + let corp = setting(true, "corp-proxy", 3128); + let lab = setting(true, "lab-proxy", 8888); + let ns = FakeNetworksetup::new(vec![("Wi-Fi", corp.clone(), corp.clone())]); + let mut snapshot = Snapshot::capture(&read_services(&ns, false).unwrap(), 9090); + apply(&ns, &enable_commands(&read_services(&ns, false).unwrap(), 9090)).unwrap(); + // While routing: Wi-Fi is renamed, and a second service is added on its device. + ns.rename("Wi-Fi", "Office"); + ns.add("Lab", "en0", lab.clone(), lab.clone()); + + // Turned on again: Lab's own settings are saved, not skipped as Wi-Fi's. + let services = read_services(&ns, false).unwrap(); + snapshot.extend(&services, 9090); + assert!(snapshot.services.iter().any(|s| s.name == "Lab" && s.http == lab)); + apply(&ns, &enable_commands(&services, 9090)).unwrap(); + + let services = read_services(&ns, true).unwrap(); + let (commands, _) = disable_commands(&services, Some(&snapshot), 9090); + apply(&ns, &commands).unwrap(); + let proxies = ns.proxies(); + assert_eq!(proxies[1].http, lab, "Lab gets its own settings back"); + assert!(!proxies[0].points_at(9090)); + assert_ne!(proxies[0].http, corp, "ambiguous, so Office is not handed Wi-Fi's"); + // Wi-Fi's saved settings are not lost. + let leftover = snapshot.leftover(&services).unwrap(); + assert!(leftover.services.iter().any(|s| s.name == "Wi-Fi" && s.http == corp)); + } + + #[test] + fn a_rename_beside_a_duplicate_still_matches() { + let corp = setting(true, "corp-proxy", 3128); + let lab = setting(true, "lab-proxy", 8888); + let ns = FakeNetworksetup::new(vec![ + ("Wi-Fi", corp.clone(), corp.clone()), + ("Wi-Fi Lab", lab.clone(), lab.clone()), + ]); + ns.set_device("Wi-Fi Lab", "en0"); + let snapshot = Snapshot::capture(&read_services(&ns, false).unwrap(), 9090); + apply(&ns, &enable_commands(&read_services(&ns, false).unwrap(), 9090)).unwrap(); + ns.rename("Wi-Fi", "Office"); + + let services = read_services(&ns, true).unwrap(); + let (commands, _) = disable_commands(&services, Some(&snapshot), 9090); + apply(&ns, &commands).unwrap(); + assert_eq!(ns.proxies()[0].http, corp); + assert_eq!(ns.proxies()[1].http, lab); + assert!(snapshot.leftover(&services).is_none()); + } + + #[test] + fn a_saved_entry_no_service_matches_is_kept() { + let corp = setting(true, "corp-proxy", 3128); + let ns = FakeNetworksetup::new(vec![ + ("Wi-Fi", corp.clone(), corp.clone()), + ("USB LAN", corp.clone(), corp.clone()), + ]); + let snapshot = Snapshot::capture(&read_services(&ns, false).unwrap(), 9090); + apply(&ns, &enable_commands(&read_services(&ns, false).unwrap(), 9090)).unwrap(); + ns.remove("USB LAN"); + + let leftover = snapshot.leftover(&read_services(&ns, true).unwrap()).unwrap(); + assert_eq!(leftover.port, 0); + assert_eq!(leftover.services.len(), 1); + assert_eq!(leftover.services[0].name, "USB LAN"); + assert_eq!(leftover.services[0].http, corp); + } + + #[test] + fn a_same_named_service_on_another_device_is_not_matched() { + let corp = setting(true, "corp-proxy", 3128); + let mut saved = ServiceProxy::new("Ethernet", corp.clone(), corp); + saved.id = Some("en5".into()); + let snapshot = Snapshot { port: 9090, services: vec![saved], ..Snapshot::default() }; + let mut other = ServiceProxy::new("Ethernet", setting(true, HOST, 9090), ours(9090)); + other.id = Some("en6".into()); + assert_eq!( + restore_commands(&[other], Some(&snapshot), &[9090]), + vec![ + vec!["-setwebproxystate", "Ethernet", "off"], + vec!["-setsecurewebproxystate", "Ethernet", "off"], + ] + ); + } + + #[test] + fn enabling_points_every_service_at_localhost() { + let services = vec![ServiceProxy::new( + "Wi-Fi", + ProxySetting::default(), + ProxySetting::default(), + )]; + assert_eq!( + enable_commands(&services, 9090), + vec![ + vec!["-setwebproxy", "Wi-Fi", "127.0.0.1", "9090"], + vec!["-setwebproxystate", "Wi-Fi", "on"], + vec!["-setsecurewebproxy", "Wi-Fi", "127.0.0.1", "9090"], + vec!["-setsecurewebproxystate", "Wi-Fi", "on"], + ] + ); + } + + #[test] + fn snapshot_round_trip_puts_back_exactly_what_was_there() { + let corp = setting(true, "corp-proxy", 3128); + let parked = setting(false, "old-proxy", 8888); + let ns = FakeNetworksetup::new(vec![ + ("Wi-Fi", corp.clone(), parked.clone()), + ("Ethernet", ProxySetting::default(), ProxySetting::default()), + ]); + let before = ns.proxies(); + + let services = read_services(&ns, false).unwrap(); + let snapshot = Snapshot::capture(&services, 9090); + apply(&ns, &enable_commands(&services, 9090)).unwrap(); + assert!(ns.proxies().iter().all(|s| s.http.points_at(9090) && s.https.points_at(9090))); + + let restore = + restore_commands(&read_services(&ns, true).unwrap(), Some(&snapshot), &[9090]); + apply(&ns, &restore).unwrap(); + let after = ns.proxies(); + assert_eq!(after[0], before[0]); + // A service that never had a server is turned off, keeping ours as the unused server. + assert!(!after[1].http.enabled && !after[1].https.enabled); + } + + #[test] + fn restoring_leaves_a_proxy_the_user_changed_since() { + let ns = FakeNetworksetup::new(vec![( + "Wi-Fi", + setting(true, "corp-proxy", 3128), + setting(true, "corp-proxy", 3128), + )]); + let services = read_services(&ns, false).unwrap(); + let snapshot = Snapshot::capture(&services, 9090); + apply(&ns, &enable_commands(&services, 9090)).unwrap(); + // The user points HTTPS somewhere else while ours is on. + ns.set_proxy("Wi-Fi", ours(9090), setting(true, "charles", 8888)); + + let restore = + restore_commands(&read_services(&ns, true).unwrap(), Some(&snapshot), &[9090]); + assert_eq!( + restore, + vec![ + vec!["-setwebproxy", "Wi-Fi", "corp-proxy", "3128"], + vec!["-setwebproxystate", "Wi-Fi", "on"], + ] + ); + } + + #[test] + fn restoring_without_a_snapshot_turns_off_only_our_proxies() { + let services = vec![ + ServiceProxy::new("Wi-Fi", ours(9090), ours(9090)), + // Someone else's proxy, and ours on another port. + ServiceProxy::new("Ethernet", setting(true, "corp-proxy", 9090), ours(8080)), + ]; + assert!(services[0].points_at(9090) && !services[1].points_at(9090)); + assert_eq!( + restore_commands(&services, None, &[9090]), + vec![ + vec!["-setwebproxystate", "Wi-Fi", "off"], + vec!["-setsecurewebproxystate", "Wi-Fi", "off"], + ] + ); + } + + #[test] + fn a_capture_over_our_own_leftover_saves_it_as_off() { + // A crash left Wi-Fi pointing at us with nothing saved. + let services = vec![ServiceProxy::new("Wi-Fi", ours(9090), ours(9090))]; + let snapshot = Snapshot::capture(&services, 9090); + assert!(!snapshot.services[0].http.enabled && !snapshot.services[0].https.enabled); + let restore = restore_commands(&services, Some(&snapshot), &[9090]); + assert_eq!( + restore, + vec![ + vec!["-setwebproxy", "Wi-Fi", "127.0.0.1", "9090"], + vec!["-setwebproxystate", "Wi-Fi", "off"], + vec!["-setsecurewebproxy", "Wi-Fi", "127.0.0.1", "9090"], + vec!["-setsecurewebproxystate", "Wi-Fi", "off"], + ] + ); + } + + #[test] + fn extending_keeps_the_first_capture_and_adds_new_services() { + let corp = setting(true, "corp-proxy", 3128); + let mut snapshot = + Snapshot::capture(&[ServiceProxy::new("Wi-Fi", corp.clone(), corp.clone())], 9090); + // Turned on again on another port: Wi-Fi now shows ours, and Ethernet is new. + snapshot.extend( + &[ + ServiceProxy::new("Wi-Fi", ours(9090), ours(9090)), + ServiceProxy::new("Ethernet", corp.clone(), corp.clone()), + ], + 9191, + ); + assert_eq!(snapshot.port, 9191); + assert_eq!(snapshot.services.len(), 2); + assert_eq!(snapshot.services[0].http, corp); + assert_eq!(snapshot.services[1].https, corp); + } + + /// Enables on 9090 over `parked`, then runs only the first call of the restore, as a crash + /// would leave it: the saved server is back, but on. + fn cut_short(parked: &ProxySetting) -> (FakeNetworksetup, Snapshot) { + let ns = FakeNetworksetup::new(vec![("Wi-Fi", parked.clone(), parked.clone())]); + let mut snapshot = Snapshot::capture(&read_services(&ns, false).unwrap(), 9090); + apply(&ns, &enable_commands(&read_services(&ns, false).unwrap(), 9090)).unwrap(); + let restore = + restore_commands(&read_services(&ns, true).unwrap(), Some(&snapshot), &[9090]); + snapshot.mark_restoring(&restore, &read_services(&ns, true).unwrap()); + apply(&ns, &restore[..1]).unwrap(); + assert!(ns.proxies()[0].http.enabled); + (ns, snapshot) + } + + #[test] + fn a_restore_cut_short_is_finished_on_the_next_try() { + let parked = setting(false, "old-proxy", 8888); + let (ns, snapshot) = cut_short(&parked); + let (retry, used_up) = + disable_commands(&read_services(&ns, true).unwrap(), Some(&snapshot), 9090); + assert!(used_up); + apply(&ns, &retry).unwrap(); + assert_eq!(ns.proxies()[0].http, parked); + assert_eq!(ns.proxies()[0].https, parked); + } + + #[test] + fn a_cut_short_restore_is_finished_after_a_rename() { + let parked = setting(false, "old-proxy", 8888); + let (ns, snapshot) = cut_short(&parked); + ns.rename("Wi-Fi", "Office Wi-Fi"); + let (retry, _) = + disable_commands(&read_services(&ns, true).unwrap(), Some(&snapshot), 9090); + apply(&ns, &retry).unwrap(); + assert_eq!(ns.proxies()[0].http, parked); + assert_eq!(ns.proxies()[0].https, parked); + } + + #[test] + fn a_cut_short_restore_of_a_localhost_proxy_is_finished_too() { + let parked = setting(false, HOST, 8888); + let (ns, snapshot) = cut_short(&parked); + let (retry, _) = + disable_commands(&read_services(&ns, true).unwrap(), Some(&snapshot), 9090); + apply(&ns, &retry).unwrap(); + assert_eq!(ns.proxies()[0].http, parked); + } + + #[test] + fn a_proxy_the_user_turned_on_themselves_is_left_on() { + let parked = setting(false, "corp-proxy", 3128); + let ns = FakeNetworksetup::new(vec![ + ("Wi-Fi", ProxySetting::default(), ProxySetting::default()), + ("Ethernet", parked.clone(), parked.clone()), + ]); + let snapshot = Snapshot::capture(&read_services(&ns, false).unwrap(), 9090); + apply(&ns, &enable_commands(&ns.proxies()[..1], 9090)).unwrap(); + // A VPN turns the parked corporate proxy on while ours runs on Wi-Fi. + let on = setting(true, "corp-proxy", 3128); + ns.set_proxy("Ethernet", on.clone(), on.clone()); + let (commands, _) = + disable_commands(&read_services(&ns, true).unwrap(), Some(&snapshot), 9090); + apply(&ns, &commands).unwrap(); + assert_eq!(ns.proxies()[1].http, on); + } + + #[test] + fn clearing_an_old_port_puts_back_what_was_saved_there() { + let corp = setting(true, "corp-proxy", 3128); + let ns = FakeNetworksetup::new(vec![ + ("Wi-Fi", corp.clone(), corp.clone()), + ("Ethernet", corp.clone(), corp.clone()), + ]); + let mut snapshot = Snapshot::capture(&read_services(&ns, false).unwrap(), 9090); + apply(&ns, &enable_commands(&read_services(&ns, false).unwrap(), 9090)).unwrap(); + // Ethernet is disabled before the move to 9191, so it stays on 9090. + ns.set_enabled("Ethernet", false); + let enabled = read_services(&ns, false).unwrap(); + snapshot.extend(&enabled, 9191); + apply(&ns, &enable_commands(&enabled, 9191)).unwrap(); + + let (commands, used_up) = + disable_commands(&read_services(&ns, true).unwrap(), Some(&snapshot), 9090); + assert!(!used_up); + apply(&ns, &commands).unwrap(); + assert_eq!(ns.proxies()[1].http, corp); + assert!(ns.proxies()[0].http.points_at(9191), "live routing kept"); + } + + #[test] + fn the_fake_refuses_an_unknown_service() { + let ns = FakeNetworksetup::new(vec![]); + assert!(ns.run(&["-getwebproxy".into(), "Nope".into()]).is_err()); + } +} diff --git a/crates-tauri/yaak-proxy-helper/src/process.rs b/crates-tauri/yaak-proxy-helper/src/process.rs new file mode 100644 index 000000000..d2bb81a1c --- /dev/null +++ b/crates-tauri/yaak-proxy-helper/src/process.rs @@ -0,0 +1,77 @@ +//! Telling whether the process that turned the system proxy on is still the same process. A +//! pid alone is not enough once it can be reused, after a reboot for one, so the owner is the +//! pid and the time it started. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Owner { + pub pid: i32, + /// Microseconds since the epoch. + pub started: u64, +} + +pub trait Processes { + /// When `pid` started, while it runs. + fn start_time(&self, pid: i32) -> Option; + + fn owner(&self, pid: i32) -> Option { + Some(Owner { pid, started: self.start_time(pid)? }) + } + + fn is_alive(&self, owner: &Owner) -> bool { + self.start_time(owner.pid) == Some(owner.started) + } +} + +/// This Mac's processes, through `proc_pidinfo`. +pub struct SystemProcesses; + +impl Processes for SystemProcesses { + fn start_time(&self, pid: i32) -> Option { + if pid <= 0 { + return None; + } + let mut info = std::mem::MaybeUninit::::zeroed(); + let size = std::mem::size_of::() as i32; + // SAFETY: `info` is a buffer of exactly `size` bytes for PROC_PIDTBSDINFO to fill. + let written = unsafe { + libc::proc_pidinfo(pid, libc::PROC_PIDTBSDINFO, 0, info.as_mut_ptr().cast(), size) + }; + if written != size { + return None; + } + // SAFETY: proc_pidinfo filled the whole struct. + let info = unsafe { info.assume_init() }; + // A zombie has exited; only its parent has yet to notice. + if info.pbi_status == libc::SZOMB { + return None; + } + Some(info.pbi_start_tvsec * 1_000_000 + info.pbi_start_tvusec) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn knows_this_process_and_not_a_missing_one() { + let me = SystemProcesses.owner(std::process::id() as i32).unwrap(); + assert!(SystemProcesses.is_alive(&me)); + // Same pid, different start: the pid was reused. + assert!(!SystemProcesses.is_alive(&Owner { started: me.started + 1, ..me })); + assert_eq!(SystemProcesses.start_time(0), None); + assert_eq!(SystemProcesses.start_time(i32::MAX), None); + } + + #[test] + fn a_child_that_exited_is_not_alive() { + let mut child = std::process::Command::new("/usr/bin/true").spawn().unwrap(); + let owner = SystemProcesses.owner(child.id() as i32); + child.wait().unwrap(); + if let Some(owner) = owner { + assert!(!SystemProcesses.is_alive(&owner)); + } + } +} diff --git a/crates-tauri/yaak-proxy-helper/src/protocol.rs b/crates-tauri/yaak-proxy-helper/src/protocol.rs new file mode 100644 index 000000000..b3014c31f --- /dev/null +++ b/crates-tauri/yaak-proxy-helper/src/protocol.rs @@ -0,0 +1,151 @@ +//! What the app and the daemon say to each other: one JSON object per line each way, a +//! [`Request`] and its [`Response`]. + +use serde::{Deserialize, Serialize}; + +/// The lowest port the system proxy may point at: never a privileged one. +pub const MIN_PORT: u16 = 1024; + +/// The longest request line read, far more than any request needs. +pub const MAX_LINE: usize = 4096; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "cmd", rename_all = "snake_case", deny_unknown_fields)] +pub enum Request { + /// What the daemon has done to the system proxy. + Get, + /// Points every enabled service at `127.0.0.1:`, saving the settings it replaces, + /// and restores them once `owner_pid` exits. Off restores them now. + Set { + enabled: bool, + port: u16, + owner_pid: i32, + }, + /// Puts back the saved settings. + Restore, + /// Restores and deletes the daemon's state, before the app unregisters it, which stops it. + Unregister, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Status { + /// The daemon has pointed the system proxy at the engine and will restore it. + pub active: bool, + pub port: Option, + pub owner_pid: Option, + pub version: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Response { + pub ok: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, +} + +impl Response { + pub fn ok(status: Status) -> Self { + Self { ok: true, error: None, status: Some(status) } + } + + pub fn error(message: impl Into) -> Self { + Self { ok: false, error: Some(message.into()), status: None } + } + + pub fn into_result(self) -> Result { + if self.ok { + Ok(self.status.unwrap_or_default()) + } else { + Err(self.error.unwrap_or_else(|| "The helper refused the request".to_string())) + } + } +} + +/// Reads and checks one request line. The host is not a field: it is always `127.0.0.1`. +pub fn parse_request(line: &str) -> Result { + let request: Request = + serde_json::from_str(line.trim()).map_err(|e| format!("Invalid request: {e}"))?; + if let Request::Set { enabled: true, port, owner_pid } = request { + if port < MIN_PORT { + return Err(format!("Port {port} is outside {MIN_PORT} to 65535")); + } + if owner_pid <= 0 { + return Err(format!("Invalid owner pid {owner_pid}")); + } + } + Ok(request) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_each_command() { + assert_eq!(parse_request(r#"{"cmd":"get"}"#), Ok(Request::Get)); + assert_eq!(parse_request("{\"cmd\":\"restore\"}\n"), Ok(Request::Restore)); + assert_eq!(parse_request(r#"{"cmd":"unregister"}"#), Ok(Request::Unregister)); + assert_eq!( + parse_request(r#"{"cmd":"set","enabled":true,"port":9090,"owner_pid":42}"#), + Ok(Request::Set { enabled: true, port: 9090, owner_pid: 42 }) + ); + } + + #[test] + fn refuses_ports_outside_1024_to_65535() { + for port in [0, 80, 1023] { + let line = format!(r#"{{"cmd":"set","enabled":true,"port":{port},"owner_pid":42}}"#); + assert!(parse_request(&line).is_err(), "port {port}"); + } + assert!( + parse_request(r#"{"cmd":"set","enabled":true,"port":65536,"owner_pid":42}"#).is_err() + ); + assert!(parse_request(r#"{"cmd":"set","enabled":true,"port":-1,"owner_pid":42}"#).is_err()); + assert!( + parse_request(r#"{"cmd":"set","enabled":true,"port":1024,"owner_pid":42}"#).is_ok() + ); + assert!( + parse_request(r#"{"cmd":"set","enabled":true,"port":65535,"owner_pid":42}"#).is_ok() + ); + } + + #[test] + fn refuses_a_host() { + let line = r#"{"cmd":"set","enabled":true,"port":9090,"owner_pid":42,"host":"10.0.0.1"}"#; + assert!(parse_request(line).unwrap_err().contains("host")); + } + + #[test] + fn refuses_a_bad_owner_and_junk() { + assert!( + parse_request(r#"{"cmd":"set","enabled":true,"port":9090,"owner_pid":0}"#).is_err() + ); + assert!(parse_request(r#"{"cmd":"set","enabled":true,"port":9090}"#).is_err()); + assert!(parse_request(r#"{"cmd":"rm -rf"}"#).is_err()); + assert!(parse_request("not json").is_err()); + assert!(parse_request("").is_err()); + } + + #[test] + fn turning_off_needs_no_valid_port() { + // Off restores whatever the daemon saved; the port only widens what counts as ours. + assert!(parse_request(r#"{"cmd":"set","enabled":false,"port":0,"owner_pid":0}"#).is_ok()); + } + + #[test] + fn responses_round_trip() { + let ok = Response::ok(Status { + active: true, + port: Some(9090), + owner_pid: Some(1), + version: "1".into(), + }); + let line = serde_json::to_string(&ok).unwrap(); + assert_eq!(serde_json::from_str::(&line).unwrap(), ok); + assert_eq!(Response::error("no").into_result(), Err("no".to_string())); + } +} diff --git a/package.json b/package.json index 3947c1349..4ba055d42 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "client:dev": "node scripts/run-dev.mjs client", "client:bundle": "node scripts/run-build.mjs client --config crates-tauri/yaak-app-client/tauri.release.conf.json --no-sign", "proxy:build": "node scripts/run-build.mjs proxy", + "proxy:bundle": "node scripts/bundle-proxy.mjs", "proxy:dev": "node scripts/run-dev.mjs proxy", "web:dev": "run-p web:dev:*", "web:dev:app": "node scripts/run-web.mjs dev", @@ -110,6 +111,8 @@ "client:tauri-before-build": "npm run bootstrap", "client:tauri-before-dev": "node scripts/run-workspaces-dev.mjs apps/yaak-client", "proxy:tauri-before-build": "npm run bootstrap", + "proxy:tauri-before-bundle": "run-s bootstrap proxy:vendor-helper", + "proxy:vendor-helper": "node scripts/vendor-proxy-helper.cjs", "proxy:tauri-before-dev": "node scripts/run-workspaces-dev.mjs apps/yaak-proxy" }, "dependencies": { diff --git a/scripts/bundle-proxy.mjs b/scripts/bundle-proxy.mjs new file mode 100644 index 000000000..ee8089b81 --- /dev/null +++ b/scripts/bundle-proxy.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node + +/** + * Builds Yaak Proxy as a `.app` with its privileged helper inside, signed when a Developer + * ID Application identity is in the keychain (or named by APPLE_SIGNING_IDENTITY). The helper + * only registers from a signed bundle, and only answers an app signed by the same team, so + * the team is taken from the identity and built into the helper. + */ + +import { execFileSync, spawnSync } from "child_process"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.join(__dirname, ".."); + +/** + * The keychain's Developer ID Application identities, by hash, since names can repeat. With + * `wanted`, only the one with that hash or name. + */ +function findIdentity(wanted) { + try { + const output = execFileSync("security", ["find-identity", "-v", "-p", "codesigning"]); + const pattern = + /^\s*\d+\) ([0-9A-F]{40}) "(Developer ID Application: .+ \(([A-Z0-9]{10})\))"$/gm; + for (const match of output.toString().matchAll(pattern)) { + if (wanted == null || wanted === match[1] || wanted === match[2]) { + return { hash: match[1], name: match[2], team: match[3] }; + } + } + return null; + } catch { + return null; + } +} + +const env = { ...process.env }; +const args = ["--bundles", "app", "--config", "crates-tauri/yaak-app-proxy/tauri.bundle.conf.json"]; + +if (env.APPLE_SIGNING_IDENTITY) { + console.log("Signing with APPLE_SIGNING_IDENTITY"); + const team = + env.APPLE_SIGNING_IDENTITY.match(/\(([A-Z0-9]{10})\)$/)?.[1] ?? + findIdentity(env.APPLE_SIGNING_IDENTITY)?.team; + env.YAAK_PROXY_TEAM_ID ??= env.APPLE_TEAM_ID ?? team; + if (env.YAAK_PROXY_TEAM_ID == null) { + console.error( + "Could not tell the signing team, which the helper must know. Set YAAK_PROXY_TEAM_ID or APPLE_TEAM_ID.", + ); + process.exit(1); + } +} else { + const identity = process.platform === "darwin" ? findIdentity() : null; + if (identity) { + console.log(`Signing with "${identity.name}"`); + env.APPLE_SIGNING_IDENTITY = identity.hash; + env.YAAK_PROXY_TEAM_ID ??= identity.team; + } else { + console.warn( + "No Developer ID Application identity found, so the bundle is unsigned and its helper cannot be registered", + ); + args.push("--no-sign"); + } +} +if (env.YAAK_PROXY_TEAM_ID == null) delete env.YAAK_PROXY_TEAM_ID; + +const result = spawnSync( + process.execPath, + [path.join(__dirname, "run-build.mjs"), "proxy", ...args, ...process.argv.slice(2)], + { cwd: rootDir, stdio: "inherit", env }, +); +process.exit(result.status ?? 1); diff --git a/scripts/vendor-proxy-helper.cjs b/scripts/vendor-proxy-helper.cjs new file mode 100644 index 000000000..49d01c1ca --- /dev/null +++ b/scripts/vendor-proxy-helper.cjs @@ -0,0 +1,49 @@ +/** + * Builds Yaak Proxy's privileged helper and places it where the proxy bundle's + * `externalBin` expects it, named for the target triple. The helper is macOS only. + */ + +const { execFileSync } = require("node:child_process"); +const { chmodSync, copyFileSync, mkdirSync } = require("node:fs"); +const path = require("node:path"); + +if (process.platform !== "darwin") { + console.log("The proxy helper is macOS only, so there is nothing to vendor"); + process.exit(0); +} + +const rootDir = path.join(__dirname, ".."); +const destDir = path.join(rootDir, "crates-tauri", "yaak-app-proxy", "vendored", "helper"); +const target = process.env.TAURI_ENV_TARGET_TRIPLE || hostTriple(); + +function hostTriple() { + const output = execFileSync("rustc", ["-vV"]).toString(); + return output.match(/^host: (\S+)$/m)[1]; +} + +/** Where Cargo builds, which CARGO_TARGET_DIR or a config's build.target-dir can move */ +function targetDir() { + const args = ["metadata", "--format-version", "1", "--no-deps"]; + return JSON.parse(execFileSync("cargo", args, { cwd: rootDir })).target_directory; +} + +function build(triple) { + execFileSync( + "cargo", + ["build", "--package", "yaak-proxy-helper", "--release", "--target", triple], + { cwd: rootDir, stdio: "inherit" }, + ); + return path.join(targetDir(), triple, "release", "yaak-proxy-helper"); +} + +mkdirSync(destDir, { recursive: true }); +const dest = path.join(destDir, `yaak-proxy-helper-${target}`); +if (target === "universal-apple-darwin") { + const arm = build("aarch64-apple-darwin"); + const intel = build("x86_64-apple-darwin"); + execFileSync("lipo", ["-create", "-output", dest, arm, intel], { stdio: "inherit" }); +} else { + copyFileSync(build(target), dest); +} +chmodSync(dest, 0o755); +console.log(`Vendored the proxy helper to ${path.relative(rootDir, dest)}`);