Privileged helper for system proxy (#796)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Gregory SchierandClaude Opus 5.5 authored and GitHub committed 2026-10-30 00:41:00 -07:00
1 parent 4d38a482f6
commit f7aea62c07
33 files changed
+3536 -176

No files matched your search

Generated
+29 -2
View File
@@ -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",
]
+2
View File
@@ -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" }
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

@@ -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<SystemProxyStatus>(["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 (
<Dialog
open={open}
onClose={close}
size="dynamic"
title="Turn on Yaak Proxy under Allow in the Background"
description="In System Settings > General > Login Items. Newer macOS calls the list Background App Activity."
className="w-[34rem]"
>
<div className="grid gap-3 pb-4">
<div className="relative">
<img
src={loginItems}
alt="System Settings, with Yaak Proxy's toggle in the list of apps allowed in the background"
className="block w-full rounded-md border border-border-subtle"
/>
<div
aria-hidden
className="absolute rounded-md ring-2 ring-primary bg-primary/10 pointer-events-none"
style={{
left: `${YAAK_PROXY_ROW.left}%`,
top: `${YAAK_PROXY_ROW.top}%`,
width: `${YAAK_PROXY_ROW.width}%`,
height: `${YAAK_PROXY_ROW.height}%`,
}}
/>
</div>
{register.error != null && <Banner color="danger">{register.error.message}</Banner>}
</div>
<DialogFooter
leftSlot={
waiting && (
<span className="flex items-center gap-2 text-sm text-text-subtle">
<LoadingIcon size="xs" />
Waiting for approval
</span>
)
}
actions={[
{ label: waiting ? "Close" : "Cancel", onClick: close },
{
label: "Open System Settings",
color: "primary",
isLoading: register.isPending,
onClick: () => register.mutate({}),
},
]}
/>
</Dialog>
);
}
@@ -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() {
</div>
</SidebarLayout>
<SaveDialog />
<HelperDialog />
<Toasts />
</div>
);
+91 -4
View File
@@ -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() {
</KeepMenuOpen>
),
},
{
type: "content",
hidden: systemProxy.data?.supported !== true,
label: systemProxy.data != null && (
<HelperMenuRow
status={systemProxy.data}
onOpenDialog={() => {
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<CaStatus>(["ca_status", {}], status),
@@ -279,9 +312,63 @@ export function useSystemProxySwitch(setup: ReturnType<typeof useProxySetup>) {
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<SystemProxyStatus>(["system_proxy_status", {}], status),
});
const row = helperRow(status.helper, status.backend);
const error = unregister.error;
return (
<MenuRow
icon={
<Icon
icon={status.helper === "enabled" ? "badge_check" : "lock"}
size="sm"
color={status.helper === "enabled" ? "success" : "secondary"}
/>
}
title={row.title}
description={
error != null ? <span className="text-danger">{error.message}</span> : row.description
}
action={
row.action != null && (
<Button
size="2xs"
variant="border"
color={row.action === "unregister" ? "default" : "primary"}
isLoading={unregister.isPending}
onClick={(e) => {
e.stopPropagation();
if (row.action === "unregister") unregister.mutate({});
else onOpenDialog();
}}
>
{row.action === "install"
? "Install"
: row.action === "approve"
? "Approve"
: "Unregister"}
</Button>
)
}
/>
);
}
/** Saves whether the system proxy turns on as the app launches */
function useSetSystemProxyAtLaunch() {
const store = useStore();
+6
View File
@@ -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<number | null>(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<SplitLayoutLayout>("flow_layout", "vertical");
+117 -1
View File
@@ -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<HelperState, true> = {
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");
+87 -3
View File
@@ -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";
+11
View File
@@ -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<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
@@ -19,6 +20,16 @@ export function subscribe<T>(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<void> {
return getCurrentWebviewWindow().show();
+34
View File
@@ -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);
});
});
+25
View File
@@ -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,
};
}
+9
View File
@@ -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"] }
+17 -2
View File
@@ -179,6 +179,12 @@ otherOwner: OtherOwner | null, };
export type GlobalAction = "recording_toggle" | "flows_clear";
export type HelperRegisterRequest = Record<string, never>;
export type HelperState = "unavailable" | "not_registered" | "requires_approval" | "enabled" | "not_found";
export type HelperUnregisterRequest = Record<string, never>;
/**
* 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<ServiceProxy>, backend: SystemProxyBackend,
/**
* The privileged helper's registration, which decides the backend.
*/
helper: HelperState,
/**
* This platform has a system proxy the app can set.
*/
+222
View File
@@ -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<PathBuf> {
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<Retained<SMAppService>> {
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<HelperState, String> {
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<HelperState, String> {
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<Status, String> {
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<Status, String> {
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<HelperState, String> {
Err("The helper is only available on macOS".to_string())
}
#[cfg(not(target_os = "macos"))]
pub fn unregister() -> Result<HelperState, String> {
Ok(HelperState::Unavailable)
}
+99 -6
View File
@@ -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<SystemProxyStatus, String> {
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<SystemProxyStatus, String> {
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<SystemProxyStatus, String> {
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<bool, RpcError> {
@@ -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<SystemProxyStatus, RpcError> {
blocking(move || ctx.register_helper()).await
}
async fn helper_unregister(
ctx: ProxyCtx,
_req: HelperUnregisterRequest,
) -> Result<SystemProxyStatus, RpcError> {
blocking(move || ctx.unregister_helper()).await
}
async fn blocking<T: Send + 'static>(
f: impl FnOnce() -> Result<T, String> + Send + 'static,
) -> Result<T, RpcError> {
@@ -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();
+319 -158
View File
@@ -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:<port>`. 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<ServiceProxy>,
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<Option<Snapshot>, 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:<port>`.
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<u16> {
// 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<String> {
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<String>]) -> 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<Vec<String>> {
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<String, String> {
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<Vec<ServiceProxy>, 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<SystemProxyStatus, String> {
let services = imp::services(false)?;
pub fn status(
port: u16,
backend: SystemProxyBackend,
helper: HelperState,
) -> Result<SystemProxyStatus, String> {
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:<port>`, 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:<port>`, 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<SystemProxyStatus, String> {
Ok(SystemProxyStatus { enabled: false, port, services: Vec::new(), backend, supported: false })
pub fn status(
port: u16,
backend: SystemProxyBackend,
helper: HelperState,
) -> Result<SystemProxyStatus, String> {
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<usize>,
) -> impl FnOnce(&[Vec<String>]) -> 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<String>]| 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<String>]| 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]
+2
View File
@@ -2,6 +2,8 @@
# will have compiled files and executables
target/
vendored/*
gen/*
**/permissions/autogenerated
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Registered by the app with SMAppService.daemon(plistName:), approved once in
System Settings > General > Login Items & Extensions. -->
<key>Label</key>
<string>app.yaak.proxy.helper</string>
<key>BundleProgram</key>
<string>Contents/MacOS/yaak-proxy-helper</string>
<key>AssociatedBundleIdentifiers</key>
<array>
<string>app.yaak.proxy</string>
<string>app.yaak.proxy.dev</string>
</array>
<key>RunAtLoad</key>
<true/>
<!-- Restarted after a crash, not after a clean exit (unregistered, or stopped). -->
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>StandardErrorPath</key>
<string>/Library/Logs/app.yaak.proxy.helper.log</string>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- The hardened runtime with no exceptions. Not sandboxed: the app runs a local
proxy server and changes the system proxy through its helper. -->
</dict>
</plist>
+5
View File
@@ -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();
}
@@ -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"
}
}
}
}
+34
View File
@@ -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"
+121
View File
@@ -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<Self, String> {
let fd = stream.as_raw_fd();
let mut pid: libc::pid_t = 0;
let mut len = std::mem::size_of::<libc::pid_t>() 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());
}
}
@@ -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<Active>,
/// 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<Snapshot>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Active {
pub owner: Owner,
pub snapshot: Snapshot,
}
pub struct Daemon<N, P> {
ns: N,
processes: P,
state_path: PathBuf,
state: Mutex<State>,
}
impl<N: Networksetup, P: Processes> Daemon<N, P> {
/// 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<Status, String> {
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<u16>) -> Result<Status, String> {
let mut state = self.lock();
self.restore_locked(&mut state, port)?;
Ok(status(&state))
}
fn restore_locked(&self, state: &mut State, port: Option<u16>) -> 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<Status, String> {
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<AtomicUsize>,
max: usize,
}
/// A connection's place among [`Slots`], given back when dropped.
pub struct Slot(Arc<AtomicUsize>);
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<Slot> {
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<N: Networksetup, P: Processes>(
stream: UnixStream,
peer: Peer,
daemon: &Daemon<N, P>,
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<dyn Verifier> = 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::sigset_t>();
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<HashMap<i32, u64>>);
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<u64> {
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<FakeNetworksetup, FakeProcesses> {
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<FakeNetworksetup, FakeProcesses>, 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<Response>, Vec<ProxySetting>) {
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()) }
}
}
}
+68
View File
@@ -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::<Vec<_>>()
.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);
}
}
@@ -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);
}
@@ -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:<port>`, 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<String>,
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:<port>`.
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<u16>,
pub services: Vec<ServiceProxy>,
/// 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<String>], 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<ServiceProxy> = 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<u16> {
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<Snapshot> {
let unmatched: Vec<ServiceProxy> = 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<Vec<String>>, 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<String> {
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:<hardware 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:<port>`.
pub fn enable_commands(services: &[ServiceProxy], port: u16) -> Vec<Vec<String>> {
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<Vec<String>> {
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<String, String>;
}
/// `/usr/sbin/networksetup` itself.
pub struct SystemNetworksetup;
impl Networksetup for SystemNetworksetup {
fn run(&self, args: &[String]) -> Result<String, String> {
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<Vec<ServiceProxy>, 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<String>]) -> 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<Vec<FakeService>>,
/// Every change made, in order.
pub changes: Mutex<Vec<Vec<String>>>,
/// A service whose changes fail, as one removed mid-change would.
pub failing: Mutex<Option<String>>,
}
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<ServiceProxy> {
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<String, String> {
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());
}
}
@@ -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<u64>;
fn owner(&self, pid: i32) -> Option<Owner> {
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<u64> {
if pid <= 0 {
return None;
}
let mut info = std::mem::MaybeUninit::<libc::proc_bsdinfo>::zeroed();
let size = std::mem::size_of::<libc::proc_bsdinfo>() 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));
}
}
}
@@ -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:<port>`, 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<u16>,
pub owner_pid: Option<i32>,
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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<Status>,
}
impl Response {
pub fn ok(status: Status) -> Self {
Self { ok: true, error: None, status: Some(status) }
}
pub fn error(message: impl Into<String>) -> Self {
Self { ok: false, error: Some(message.into()), status: None }
}
pub fn into_result(self) -> Result<Status, String> {
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<Request, String> {
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::<Response>(&line).unwrap(), ok);
assert_eq!(Response::error("no").into_result(), Err("no".to_string()));
}
}
+3
View File
@@ -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": {
+72
View File
@@ -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);
+49
View File
@@ -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)}`);