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 000000000..fad5628f6 Binary files /dev/null and b/apps/yaak-proxy/assets/login-items.png differ 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)}`);